~bigbes/sr-ht-dolt

f9c82b0b52a29d95365455e3a975ca9e3608ef2b — Eugene Blikh 5 days ago 0f6d50a
beads: extract the projection out of web/

web/beads.go held the one reading of the beads schema — the table
fingerprint, the lane bucketing, the ready rule, the status categories,
the transitive dependency walk, the event humanizer, the filter model and
the milestone rollup — where a second consumer could not reach it. The
MCP surface and the cross-database ready page both need it.

Move it to a new root package beads/ that depends on browse/ and the
standard library only: rows in, view model out, no net/http, no
html/template, no core. The BrowseSession seam is declared consumer-side
there and names the one method the projections call, so web's larger
BrowseSession satisfies it structurally and a session passes straight
through.

web/beads.go and web/milestones.go keep only their View adapters — slug,
label, template, Applies, and the hand-off of ref and query. The
templates are unchanged: the moved types keep their names and their
display methods, so every dot still resolves.

Pure move plus the beadsMax -> beads.Max export rename. The projection
tests move with the code and become testify; the render tests stay in
web/ unchanged. Same 63 tests pass before and after.
A beads/beads.go => beads/beads.go +116 -0
@@ 0,0 1,116 @@
// 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"
	}
}

A beads/beads_test.go => beads/beads_test.go +493 -0
@@ 0,0 1,493 @@
package beads

import (
	"context"
	"fmt"
	"net/url"
	"sort"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// --- fixtures ----------------------------------------------------------------

// fakeSession is a BrowseSession over canned per-table pages — the whole seam
// this package reads through. A table absent from rowsByTable is reported as
// ErrTableNotFound, mirroring the store, so the optional-table degradation in
// readRowsOptional is exercised rather than assumed.
type fakeSession struct {
	rowsByTable map[string]*browse.RowPage
}

func (s *fakeSession) Rows(_ context.Context, _, table string, _, _ int) (*browse.RowPage, error) {
	if p, ok := s.rowsByTable[table]; ok {
		return p, nil
	}
	return nil, fmt.Errorf("%w: %s", browse.ErrTableNotFound, table)
}

// beadsTables is a schema fingerprint that Applies should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
	return []browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{
			{Name: "id", PrimaryKey: true}, {Name: "status"},
		}},
		{Name: "dependencies", Columns: []browse.ColumnInfo{{Name: "id", PrimaryKey: true}}},
		{Name: "labels"},
	}
}

// beadsFixture wires a fakeSession whose per-table Rows model a small parade:
//   - i-open   : open, ready         → Lined Up
//   - i-prog   : in_progress         → Rolling
//   - i-done   : closed              → Past Stand
//   - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
//     and also carries is_blocked=1  → Stalled
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
	issues := &browse.RowPage{
		// Column order chosen so nothing is at a "natural" index; is_blocked is last
		// and close_reason sits mid-row so name (not positional) mapping is exercised.
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
		Rows: [][]string{
			{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},
			{"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "NULL", "NULL", "0"},
			{"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "2024-01-04", "Fixed in commit abc123", "0"},
			{"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "NULL", "NULL", "1"},
		},
		Total: 4,
	}
	// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
	// i-open is depended on by i-blocked → i-open.Blocks == 1.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{
			{"d1", "i-blocked", "i-open", "blocks"},
		},
		Total: 1,
	}
	labels := &browse.RowPage{
		Columns: []string{"issue_id", "label"},
		Rows: [][]string{
			{"i-open", "backend"},
			{"i-open", "urgent"},
		},
		Total: 2,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"},
			{"in_progress", "in_progress"},
			{"closed", "closed"},
		},
		Total: 3,
	}
	comments := &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows: [][]string{
			{"i-open", "alice", "first!", "2024-01-05"},
			{"i-prog", "bob", "not this one", "2024-01-06"},
		},
		Total: 2,
	}
	// i-done's closure is recorded as a `closed` audit event carrying the reason
	// (the only place the reason now surfaces — there is no standalone block).
	events := &browse.RowPage{
		Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
		Rows: [][]string{
			{"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"},
		},
		Total: 1,
	}
	return &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"labels":          labels,
			"custom_statuses": statuses,
			"comments":        comments,
			"events":          events,
		},
	}
}

// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *BeadsData, slug string) *BeadsLane {
	for i := range d.Lanes {
		if d.Lanes[i].Slug == slug {
			return &d.Lanes[i]
		}
	}
	return nil
}

// cardIDs lists the ids of a lane's cards.
func cardIDs(l *BeadsLane) []string {
	if l == nil {
		return nil
	}
	out := make([]string, len(l.Issues))
	for i, c := range l.Issues {
		out[i] = c.ID
	}
	return out
}

// --- Applies -----------------------------------------------------------------

func TestBeadsApplies(t *testing.T) {
	assert.True(t, Applies(beadsTables()),
		"Applies should be true when issues+dependencies (with id+status) present")
	// Missing dependencies → not a beads DB.
	assert.False(t, Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
	}), "Applies should be false without a dependencies table")
	// issues present but lacking status column → guard rejects.
	assert.False(t, Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
		{Name: "dependencies"},
	}), "Applies should be false when issues lacks a status column")
	// Unrelated schema.
	assert.False(t, Applies([]browse.TableInfo{{Name: "widgets"}}),
		"Applies should be false for an unrelated schema")
}

// --- board mode --------------------------------------------------------------

func TestBeadsBuildBoardLanes(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
	require.NoError(t, err)
	require.Equal(t, "board", d.Mode)

	checks := map[string][]string{
		"rolling":    {"i-prog"},
		"lined-up":   {"i-open"},
		"stalled":    {"i-blocked"},
		"past-stand": {"i-done"},
	}
	for slug, want := range checks {
		assert.Equal(t, want, cardIDs(laneBySlug(d, slug)), "lane %s", slug)
	}

	assert.Equal(t, BeadsCounts{Rolling: 1, LinedUp: 1, Stalled: 1, PastStand: 1, Total: 4}, d.Counts)
	assert.Equal(t, 4, d.Total)
}

func TestBeadsBuildBoardCounts(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
	require.NoError(t, err)

	// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
	blocked := laneBySlug(d, "stalled").Issues[0]
	assert.Equal(t, "i-blocked", blocked.ID)
	assert.Equal(t, 1, blocked.BlockedBy)
	assert.Equal(t, 0, blocked.Blocks)
	open := laneBySlug(d, "lined-up").Issues[0]
	assert.Equal(t, "i-open", open.ID)
	assert.Equal(t, 1, open.Blocks)
	assert.Equal(t, 0, open.BlockedBy)
	// Labels attach by issue_id.
	assert.Equal(t, []string{"backend", "urgent"}, open.Labels)
}

// boardIDs returns every card id on the board, across all lanes.
func boardIDs(d *BeadsData) []string {
	var out []string
	for i := range d.Lanes {
		out = append(out, cardIDs(&d.Lanes[i])...)
	}
	sort.Strings(out)
	return out
}

func TestBeadsBoardFilters(t *testing.T) {
	cases := []struct {
		name  string
		query url.Values
		want  []string // sorted ids expected on the board
	}{
		{"type", url.Values{"type": {"feature"}}, []string{"i-blocked", "i-open"}},
		{"priority", url.Values{"priority": {"1"}}, []string{"i-blocked", "i-open"}},
		{"assignee", url.Values{"assignee": {"bob"}}, []string{"i-prog"}},
		{"label", url.Values{"label": {"urgent"}}, []string{"i-open"}},
		{"query-title", url.Values{"q": {"ready"}}, []string{"i-open"}},
		{"query-id", url.Values{"q": {"i-done"}}, []string{"i-done"}},
		{"combined-empty", url.Values{"type": {"feature"}, "assignee": {"bob"}}, nil},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			d, err := Build(context.Background(), beadsFixture(), "main", tc.query)
			require.NoError(t, err)
			assert.Equal(t, tc.want, boardIDs(d))
			assert.Equal(t, len(tc.want), d.Total)
			assert.Equal(t, len(tc.want), d.Counts.Total)
			assert.True(t, d.Filter.Active(), "Filter.Active() should be true when a filter is set")
		})
	}
}

func TestBeadsBoardFilterOptions(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
	require.NoError(t, err)
	o := d.FilterOpts
	assert.Equal(t, []string{"bug", "chore", "feature"}, o.Types)
	assert.Equal(t, []string{"0", "1", "2"}, o.Priorities)
	assert.Equal(t, []string{"alice", "bob", "carol", "dave"}, o.Assignees)
	assert.Equal(t, []string{"backend", "urgent"}, o.Labels)
	assert.False(t, d.Filter.Active(), "no query set, Filter.Active() should be false")
}

func TestBeadsReady(t *testing.T) {
	d, err := Build(context.Background(), beadsFixture(), "main", url.Values{})
	require.NoError(t, err)

	ready := map[string]bool{}
	for i := range d.Lanes {
		for _, c := range d.Lanes[i].Issues {
			ready[c.ID] = c.Ready
		}
	}
	// Only i-open is actionable: open + unblocked. i-prog is in-progress, i-done
	// closed, i-blocked blocked.
	assert.True(t, ready["i-open"], "i-open should be ready")
	for _, id := range []string{"i-prog", "i-done", "i-blocked"} {
		assert.False(t, ready[id], "%s should not be ready", id)
	}
	// The ready filter narrows the board to the actionable set.
	f, err := Build(context.Background(), beadsFixture(), "main", url.Values{"ready": {"1"}})
	require.NoError(t, err)
	assert.Equal(t, []string{"i-open"}, boardIDs(f))
}

func TestBeadsDepTree(t *testing.T) {
	// Chain: a --blocks--> b --blocks--> c (c closed). a depends on b depends on c.
	sess := &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "title", "status"},
				Rows:    [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "closed"}},
				Total:   3,
			},
			"dependencies": {
				Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Rows:    [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}},
				Total:   2,
			},
		},
	}
	// From a: transitive prerequisites b(0) → c(1); c is closed.
	d, err := Build(context.Background(), sess, "main", url.Values{"issue": {"a"}})
	require.NoError(t, err)
	require.Len(t, d.DependsTree, 2, "DependsTree = %+v, want [b(0) c(1)]", d.DependsTree)
	assert.Equal(t, "b", d.DependsTree[0].ID)
	assert.Equal(t, 0, d.DependsTree[0].Depth)
	assert.Equal(t, "c", d.DependsTree[1].ID)
	assert.Equal(t, 1, d.DependsTree[1].Depth)
	assert.True(t, d.DependsTree[1].Closed, "c is closed")

	// From the leaf c: transitive dependents b(0) → a(1); no prerequisites.
	dc, err := Build(context.Background(), sess, "main", url.Values{"issue": {"c"}})
	require.NoError(t, err)
	require.Len(t, dc.DependentTree, 2, "DependentTree = %+v, want [b(0) a(1)]", dc.DependentTree)
	assert.Equal(t, "b", dc.DependentTree[0].ID)
	assert.Equal(t, "a", dc.DependentTree[1].ID)
	assert.Empty(t, dc.DependsTree, "c has no prerequisites")
}

// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
	sess := &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "status", "is_blocked"},
				Rows: [][]string{
					{"a", "open", "0"},   // blocked by open b → Stalled
					{"b", "open", "0"},   // ready → Lined Up
					{"c", "open", "0"},   // "blocked" by closed d → NOT stalled → Lined Up
					{"d", "closed", "0"}, // Past Stand
				},
				Total: 4,
			},
			"dependencies": {
				Columns: []string{"issue_id", "depends_on_issue_id", "type"},
				Rows: [][]string{
					{"a", "b", "blocks"},
					{"c", "d", "blocks"},
				},
				Total: 2,
			},
		},
	}
	d, err := Build(context.Background(), sess, "main", url.Values{})
	require.NoError(t, err)
	assert.Equal(t, []string{"a"}, cardIDs(laneBySlug(d, "stalled")),
		"stalled should hold only a (blocked by an open dep)")
	assert.Equal(t, []string{"b", "c"}, cardIDs(laneBySlug(d, "lined-up")),
		"lined-up should hold b and c (c's blocker is closed)")
}

// --- detail mode -------------------------------------------------------------

func TestBeadsBuildDetail(t *testing.T) {
	q := url.Values{}
	q.Set("issue", "i-open")
	d, err := Build(context.Background(), beadsFixture(), "main", q)
	require.NoError(t, err)
	require.Equal(t, "detail", d.Mode)
	require.NotNil(t, d.Issue)
	assert.Equal(t, "i-open", d.Issue.ID)
	assert.Equal(t, "Ready to roll", d.Issue.Title)
	assert.Equal(t, []string{"backend", "urgent"}, d.Issue.Labels)
	// i-open is depended on by i-blocked (incoming), and depends on nothing.
	assert.Empty(t, d.DependsOn, "i-open depends on nothing")
	require.Len(t, d.DependedOnBy, 1)
	assert.Equal(t, "i-blocked", d.DependedOnBy[0].IssueID)
	// Only i-open's comment shows in its thread.
	require.Len(t, d.Comments, 1)
	assert.Equal(t, "alice", d.Comments[0].Author)
	assert.Equal(t, "first!", d.Comments[0].Text)
}

func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
	q := url.Values{}
	q.Set("issue", "i-blocked")
	d, err := Build(context.Background(), beadsFixture(), "main", q)
	require.NoError(t, err)
	require.Len(t, d.DependsOn, 1)
	assert.Equal(t, "i-open", d.DependsOn[0].IssueID)
	assert.Equal(t, "Ready to roll", d.DependsOn[0].Title)
	assert.Equal(t, "blocks", d.DependsOn[0].Type)
	assert.False(t, d.DependsOn[0].Closed, "i-open is open")
}

// --- epic mode + history -----------------------------------------------------

// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
func beadsEpicFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
		Rows: [][]string{
			{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
			{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
			{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
			{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
		},
		Total: 4,
	}
	// Each child is the "from" side of a parent-child edge pointing at the epic;
	// created_at/created_by let the view synthesize "added subtask" history.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
		Rows: [][]string{
			{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
			{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
			{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
		},
		Total: 3,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
		},
		Total: 3,
	}
	comments := &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows: [][]string{
			{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
			{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
		},
		Total: 2,
	}
	events := &browse.RowPage{
		Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
		Rows: [][]string{
			{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
			{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
			{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
			{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
			{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
		},
		Total: 5,
	}
	return &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"custom_statuses": statuses,
			"comments":        comments,
			"events":          events,
		},
	}
}

func TestBeadsEpicMode(t *testing.T) {
	d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
	require.NoError(t, err)
	require.Equal(t, "epic", d.Mode)
	assert.Equal(t, 3, d.SubtaskTotal)
	assert.Equal(t, 1, d.SubtaskDone)
	assert.Equal(t, 33, d.SubtaskPct())
	// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
	require.Len(t, d.Subtasks, 3)
	gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
	assert.Equal(t, []string{"i-c3", "i-c1", "i-c2"}, gotIDs, "subtask order")
	assert.Equal(t, "closed", d.Subtasks[2].Category)
}

func TestBeadsHistoryMerge(t *testing.T) {
	d, err := Build(context.Background(), beadsEpicFixture(), "main", url.Values{"issue": {"i-epic"}})
	require.NoError(t, err)

	// Only the epic's own comment shows in the Comments tab (not i-c1's).
	require.Len(t, d.Comments, 1, "comments = %+v, want just the epic's kickoff", d.Comments)
	assert.Equal(t, "kickoff", d.Comments[0].Text)
	// History merges the epic's 1 comment + 4 events + 3 synthesized subtask-add
	// entries (i-c1's own event is excluded), time-sorted.
	require.Len(t, d.History, 8, "history = %+v", d.History)
	for i := 1; i < len(d.History); i++ {
		assert.LessOrEqual(t, d.History[i-1].CreatedAt, d.History[i].CreatedAt,
			"history not time-sorted at %d", i)
	}
	assert.Equal(t, "event", d.History[0].Kind)
	assert.Equal(t, "created the issue", d.History[0].Summary)
	last := d.History[len(d.History)-1]
	assert.Equal(t, "comment", last.Kind)
	assert.Equal(t, "kickoff", last.Text)
	// Humanized change lines, and a label event collapsed to one line with no
	// redundant "Added label:" body.
	var sawStatus, sawUpdate, sawLabel, sawSubtask bool
	for _, a := range d.History {
		switch a.Summary {
		case "changed status to in_progress":
			sawStatus = true
		case "updated priority to 0":
			sawUpdate = true
		case "added label milestone:m3":
			sawLabel = true
			assert.Empty(t, a.Text, "label event should have no body")
		case "added subtask i-c1":
			sawSubtask = true
			assert.Equal(t, "dep", a.Kind)
			assert.Equal(t, "Eugene", a.Actor)
		}
		assert.NotContains(t, a.Text, "Added label:", "label note leaked into a history body: %+v", a)
	}
	assert.True(t, sawStatus, "history missing the status line")
	assert.True(t, sawUpdate, "history missing the update line")
	assert.True(t, sawLabel, "history missing the label line")
	assert.True(t, sawSubtask, "history missing the subtask-add line")
}

A beads/build.go => beads/build.go +463 -0
@@ 0,0 1,463 @@
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) (*BeadsData, 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 := BeadsFilter{
		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 []BeadCard
	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 := BeadCard{
			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 [][]BeadCard{rolling, linedUp, stalled, pastStand} {
		sortCards(lane, created)
	}

	data := &BeadsData{
		Mode: "board",
		Lanes: []BeadsLane{
			// 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: BeadsCounts{
			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,
) *BeadsData {
	// 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 := &BeadsData{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 = &BeadIssue{
		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) BeadEdge {
		st := statusByIssue[id]
		return BeadEdge{
			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 := BeadSubtask{
					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, BeadComment{Author: author, Text: text, CreatedAt: at})
			data.History = append(data.History, BeadActivity{
				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, BeadActivity{
				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) BeadsFilterOptions {
	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 BeadsFilterOptions{
		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 []BeadCard, 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 []BeadSubtask) {
	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 []BeadActivity) {
	sort.SliceStable(acts, func(i, j int) bool {
		return acts[i].CreatedAt < acts[j].CreatedAt
	})
}

A beads/deps.go => beads/deps.go +102 -0
@@ 0,0 1,102 @@
package beads

import "strings"

// depTreeMaxDepth / depTreeMaxNodes bound the transitive walk so a dense or
// cyclic graph can never blow up a detail page.
const (
	depTreeMaxDepth = 6
	depTreeMaxNodes = 200
)

// depLink is one outgoing edge in a dependency adjacency map: the neighbor id
// and the edge's dependency type.
type depLink struct {
	to  string
	typ string
}

// buildDepTree walks the adjacency from root (exclusive) breadth-consistent
// pre-order, flattening the reachable set into indented nodes. Each issue
// appears once (first path wins); depth and node count are bounded so a dense
// or cyclic graph is safe.
func buildDepTree(root string, adj map[string][]depLink, titleOf, statusOf, catByStatus map[string]string) []BeadTreeNode {
	var out []BeadTreeNode
	visited := map[string]bool{root: true}
	var dfs func(id string, depth int)
	dfs = func(id string, depth int) {
		if depth > depTreeMaxDepth || len(out) >= depTreeMaxNodes {
			return
		}
		for _, lnk := range adj[id] {
			if visited[lnk.to] || len(out) >= depTreeMaxNodes {
				continue
			}
			visited[lnk.to] = true
			st := statusOf[lnk.to]
			out = append(out, BeadTreeNode{
				ID:     lnk.to,
				Title:  titleOf[lnk.to],
				Type:   lnk.typ,
				Status: st,
				Closed: statusCategory(st, catByStatus) == "closed",
				Depth:  depth,
			})
			dfs(lnk.to, depth+1)
		}
	}
	dfs(root, 0)
	return out
}

// hasTransitive reports whether a flattened tree reaches past the direct edges
// (any Depth>0 node) — the signal that it adds something the flat list doesn't.
func hasTransitive(nodes []BeadTreeNode) bool {
	for _, n := range nodes {
		if n.Depth > 0 {
			return true
		}
	}
	return false
}

// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks
// linked under an epic — still appear on the timeline. Returns ok=false when the
// edge does not touch `want` or the row has no timestamp (older schema without
// created_at: skip rather than emit a blank-dated entry).
func depActivity(want, from, to, typ, at, by string) (BeadActivity, bool) {
	if at == "" || (from != want && to != want) {
		return BeadActivity{}, false
	}
	var summary string
	switch strings.ToLower(strings.TrimSpace(typ)) {
	case "parent-child":
		if to == want {
			summary = "added subtask " + from // want is the epic/parent
		} else {
			summary = "added under epic " + to // want is the child
		}
	case "blocks":
		if from == want {
			summary = "added dependency on " + to
		} else {
			summary = from + " now depends on this"
		}
	case "related":
		// Related is symmetric; emit once (from the issue_id side) to avoid a
		// duplicate entry on both endpoints.
		if from != want {
			return BeadActivity{}, false
		}
		summary = "linked " + to + " (related)"
	default:
		if from == want {
			summary = "added " + typ + " dependency on " + to
		} else {
			return BeadActivity{}, false
		}
	}
	return BeadActivity{Kind: "dep", Event: "dependency", Actor: by, Summary: summary, CreatedAt: at}, true
}

A beads/events.go => beads/events.go +135 -0
@@ 0,0 1,135 @@
package beads

import (
	"encoding/json"
	"fmt"
	"sort"
	"strconv"
	"strings"
)

// humanizeEvent turns one audit row into a readable summary line (and optional
// body text). status_changed / updated carry a JSON new_value fragment
// ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
// markers, with closed's new_value holding the free-text close reason; label
// events keep their whole story in the comment note, so they collapse to a
// single summary line rather than a "label added" header + redundant body.
func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) {
	note = strings.TrimSpace(note)
	switch strings.ToLower(strings.TrimSpace(eventType)) {
	case "created":
		return "created the issue", ""
	case "closed":
		// new_value is the close reason (plain text), not JSON; older rows put it
		// in the note instead.
		if r := strings.TrimSpace(newVal); r != "" {
			return "closed the issue", r
		}
		return "closed the issue", note
	case "status_changed":
		if s := jsonField(newVal, "status"); s != "" {
			return "changed status to " + s, ""
		}
		return "changed status", ""
	case "updated":
		if pairs := jsonPairs(newVal); pairs != "" {
			return "updated " + pairs, ""
		}
		return "updated the issue", ""
	case "label_added":
		return labelLine(note, "added"), ""
	case "label_removed":
		return labelLine(note, "removed"), ""
	default:
		et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
		if et == "" {
			et = "changed"
		}
		return et, note
	}
}

// labelLine collapses a label event to one line. The note reads "Added label:
// <name>"; we drop everything up to the FIRST colon (the "Added label:" prefix)
// and keep the rest, so a namespaced label like "milestone:m3" survives intact
// and the summary becomes "added label milestone:m3".
func labelLine(note, verb string) string {
	name := note
	if i := strings.Index(name, ":"); i >= 0 {
		name = name[i+1:]
	}
	name = strings.TrimSpace(name)
	if name == "" {
		return verb + " a label"
	}
	return verb + " label " + name
}

// jsonField extracts one string-ish field from a JSON object fragment, or ""
// when the value is not a JSON object or the key is absent.
func jsonField(raw, key string) string {
	m := decodeJSONObject(raw)
	if m == nil {
		return ""
	}
	if v, ok := m[key]; ok {
		return scalarString(v)
	}
	return ""
}

// jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
// "updated …" summary. Keys are sorted for a deterministic line.
func jsonPairs(raw string) string {
	m := decodeJSONObject(raw)
	if len(m) == 0 {
		return ""
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, k+" to "+scalarString(m[k]))
	}
	return strings.Join(parts, ", ")
}

// decodeJSONObject parses raw into a map, tolerating the browse NULL placeholder
// and non-object payloads (returns nil rather than erroring).
func decodeJSONObject(raw string) map[string]any {
	raw = strings.TrimSpace(raw)
	if raw == "" || raw == "NULL" {
		return nil
	}
	var m map[string]any
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}
	return m
}

// scalarString renders a decoded JSON scalar the way a person would read it:
// integers without a trailing ".0", everything else via fmt.
func scalarString(v any) string {
	switch t := v.(type) {
	case string:
		return t
	case float64:
		if t == float64(int64(t)) {
			return strconv.FormatInt(int64(t), 10)
		}
		return strconv.FormatFloat(t, 'g', -1, 64)
	case bool:
		if t {
			return "true"
		}
		return "false"
	case nil:
		return ""
	default:
		return fmt.Sprintf("%v", t)
	}
}

A beads/milestones.go => beads/milestones.go +227 -0
@@ 0,0 1,227 @@
package beads

import (
	"context"
	"sort"
	"strings"
)

// milestonePrefix marks labels that name a milestone; the rollup groups by them.
const milestonePrefix = "milestone:"

// MilestoneView is the opaque .Data handed to milestones.html.
type MilestoneView struct {
	Milestones []MilestoneDetail
	Unlabeled  int // issues carrying no milestone label
	Total      int // all issues read
}

// MilestoneDetail is one milestone's rollup and the issues under it, arranged
// as a shallow hierarchy: the milestone-typed issue(s) first, then epics with
// their subtasks nested one level below, then everything else.
type MilestoneDetail struct {
	Name       string // label with the "milestone:" prefix stripped
	Label      string // full label, for filter links back to the board
	Total      int
	Done       int // closed
	InProgress int
	Open       int             // open (or unknown) — the remaining work
	Heads      []BeadCard      // issue_type == "milestone" — the milestone's own issue(s)
	Epics      []MilestoneEpic // epics in the milestone, each with its nested subtasks
	Loose      []BeadCard      // members that are neither heads, epics, nor nested subtasks
}

// MilestoneEpic is an epic inside a milestone together with the milestone
// members nested under it (parent-child edges pointing at the epic).
type MilestoneEpic struct {
	Card     BeadCard
	Done     int // closed children, for the "d/t" rollup on the epic row
	Total    int
	Children []BeadCard
}

// Pct is the milestone's completion percentage (0..100) for the progress bar.
func (m MilestoneDetail) Pct() int {
	if m.Total == 0 {
		return 0
	}
	return m.Done * 100 / m.Total
}

// BuildMilestones reads the issues and their labels, then groups by milestone
// label. An issue with several milestone labels counts under each. Missing
// labels/statuses tables degrade to empty (no milestones), never an error.
func BuildMilestones(ctx context.Context, sess BrowseSession, ref string) (*MilestoneView, error) {
	issues, _, err := readRows(ctx, sess, ref, "issues")
	if err != nil {
		return nil, err
	}
	labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
	statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
	deps, _, _ := readRowsOptional(ctx, sess, ref, "dependencies")

	// child issue → its parent-child parents; used to nest tasks under epics.
	parentsByChild := map[string][]string{}
	if deps != nil {
		cols := indexCols(deps.Columns)
		for _, r := range deps.Rows {
			if !strings.EqualFold(cell(cols, r, "type"), "parent-child") {
				continue
			}
			child := cell(cols, r, "issue_id")
			parent := cell(cols, r, "depends_on_issue_id")
			if child != "" && parent != "" {
				parentsByChild[child] = append(parentsByChild[child], parent)
			}
		}
	}

	catByStatus := map[string]string{}
	if statuses != nil {
		cols := indexCols(statuses.Columns)
		for _, r := range statuses.Rows {
			if name := cell(cols, r, "name"); name != "" {
				catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category"))
			}
		}
	}

	labelsByIssue := map[string][]string{}
	if labels != nil {
		cols := indexCols(labels.Columns)
		for _, r := range labels.Rows {
			id := cell(cols, r, "issue_id")
			lb := cell(cols, r, "label")
			if id != "" && lb != "" {
				labelsByIssue[id] = append(labelsByIssue[id], lb)
			}
		}
	}

	issueCols := indexCols(issues.Columns)
	byLabel := map[string]*MilestoneDetail{}
	cardsByLabel := map[string][]BeadCard{}
	unlabeled := 0
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		cat := statusCategory(cell(issueCols, r, "status"), catByStatus)
		card := BeadCard{
			ID:       id,
			Title:    cell(issueCols, r, "title"),
			Type:     cell(issueCols, r, "issue_type"),
			Priority: cell(issueCols, r, "priority"),
			Assignee: cell(issueCols, r, "assignee"),
			Category: cat,
		}
		seen := false
		for _, l := range labelsByIssue[id] {
			if !strings.HasPrefix(l, milestonePrefix) {
				continue
			}
			seen = true
			md := byLabel[l]
			if md == nil {
				md = &MilestoneDetail{Name: strings.TrimPrefix(l, milestonePrefix), Label: l}
				byLabel[l] = md
			}
			md.Total++
			switch cat {
			case "closed":
				md.Done++
			case "in_progress":
				md.InProgress++
			default:
				md.Open++
			}
			cardsByLabel[l] = append(cardsByLabel[l], card)
		}
		if !seen {
			unlabeled++
		}
	}

	names := make([]string, 0, len(byLabel))
	for l := range byLabel {
		names = append(names, l)
	}
	sort.Strings(names)
	out := make([]MilestoneDetail, 0, len(names))
	for _, l := range names {
		md := byLabel[l]
		md.arrange(cardsByLabel[l], parentsByChild)
		out = append(out, *md)
	}

	return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil
}

// arrange splits a milestone's member cards into the display hierarchy: heads
// (issue_type "milestone") on top, then epics with the members parent-child'ed
// under them, then the leftovers. Only members of this milestone participate —
// membership stays purely label-based; a child nests only when its epic carries
// the same milestone label.
func (md *MilestoneDetail) arrange(cards []BeadCard, parentsByChild map[string][]string) {
	epicByID := map[string]*MilestoneEpic{}
	var epics []*MilestoneEpic
	for _, c := range cards {
		switch {
		case strings.EqualFold(c.Type, "milestone"):
			md.Heads = append(md.Heads, c)
		case strings.EqualFold(c.Type, "epic"):
			e := &MilestoneEpic{Card: c}
			epicByID[c.ID] = e
			epics = append(epics, e)
		}
	}
	for _, c := range cards {
		if strings.EqualFold(c.Type, "milestone") || strings.EqualFold(c.Type, "epic") {
			continue
		}
		var home *MilestoneEpic
		for _, p := range parentsByChild[c.ID] {
			if e := epicByID[p]; e != nil {
				home = e
				break
			}
		}
		if home == nil {
			md.Loose = append(md.Loose, c)
			continue
		}
		home.Total++
		if c.Category == "closed" {
			home.Done++
		}
		home.Children = append(home.Children, c)
	}

	sortMilestoneCards(md.Heads)
	sortMilestoneCards(md.Loose)
	sort.SliceStable(epics, func(i, j int) bool {
		return milestoneCardLess(epics[i].Card, epics[j].Card)
	})
	for _, e := range epics {
		sortMilestoneCards(e.Children)
		md.Epics = append(md.Epics, *e)
	}
}

// sortMilestoneCards orders a milestone's issues open-work-first (closed sinks to
// the bottom), then by priority, then id — a stable, deterministic order.
func sortMilestoneCards(cards []BeadCard) {
	sort.SliceStable(cards, func(i, j int) bool {
		return milestoneCardLess(cards[i], cards[j])
	})
}

func milestoneCardLess(a, b BeadCard) bool {
	ca, cb := a.Category == "closed", b.Category == "closed"
	if ca != cb {
		return !ca
	}
	pa, pb := priorityRank(a.Priority), priorityRank(b.Priority)
	if pa != pb {
		return pa < pb
	}
	return a.ID < b.ID
}

A beads/milestones_test.go => beads/milestones_test.go +111 -0
@@ 0,0 1,111 @@
package beads

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// milestoneFixture: six issues across two milestones. m1 carries the full
// hierarchy — a milestone-typed head, an epic with one (closed) subtask, and a
// loose bug; m2 has a single loose feature; one issue has no milestone label.
func milestoneFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee"},
		Rows: [][]string{
			{"i-m", "Mike", "open", "0", "milestone", ""},
			{"i-e", "Echo", "open", "1", "epic", ""},
			{"i-a", "Alpha", "closed", "1", "task", "alice"}, // subtask of i-e
			{"i-b", "Bravo", "open", "0", "bug", "bob"},
			{"i-c", "Charlie", "open", "2", "feature", ""},
			{"i-d", "Delta", "open", "1", "task", ""}, // no milestone
		},
		Total: 6,
	}
	labels := &browse.RowPage{
		Columns: []string{"issue_id", "label"},
		Rows: [][]string{
			{"i-m", "milestone:m1"},
			{"i-e", "milestone:m1"},
			{"i-a", "milestone:m1"},
			{"i-b", "milestone:m1"},
			{"i-c", "milestone:m2"},
			{"i-b", "backend"}, // non-milestone label ignored by grouping
		},
		Total: 6,
	}
	deps := &browse.RowPage{
		Columns: []string{"issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{
			{"i-a", "i-e", "parent-child"},
			{"i-b", "i-a", "blocks"}, // non-hierarchy edge ignored by nesting
		},
		Total: 2,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows:    [][]string{{"open", "open"}, {"closed", "closed"}},
		Total:   2,
	}
	return &fakeSession{
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"labels":          labels,
			"dependencies":    deps,
			"custom_statuses": statuses,
		},
	}
}

func TestMilestonesBuild(t *testing.T) {
	d, err := BuildMilestones(context.Background(), milestoneFixture(), "main")
	require.NoError(t, err)

	require.Len(t, d.Milestones, 2, "milestones = %+v", d.Milestones)
	assert.Equal(t, 1, d.Unlabeled)
	assert.Equal(t, 6, d.Total)

	m1 := d.Milestones[0]
	assert.Equal(t, "m1", m1.Name)
	assert.Equal(t, 4, m1.Total)
	assert.Equal(t, 1, m1.Done)
	assert.Equal(t, 3, m1.Open)
	assert.Equal(t, 25, m1.Pct())
	// Hierarchy: the milestone-typed issue heads the list, the epic nests its
	// subtask, and the bug (whose only dep edge is "blocks") stays loose.
	require.Len(t, m1.Heads, 1, "m1 heads = %+v", m1.Heads)
	assert.Equal(t, "i-m", m1.Heads[0].ID)
	require.Len(t, m1.Epics, 1, "m1 epics = %+v", m1.Epics)
	epic := m1.Epics[0]
	assert.Equal(t, "i-e", epic.Card.ID)
	require.Len(t, epic.Children, 1, "epic = %+v", epic)
	assert.Equal(t, "i-a", epic.Children[0].ID)
	assert.Equal(t, 1, epic.Done)
	assert.Equal(t, 1, epic.Total)
	assert.Equal(t, "closed", epic.Children[0].Category, "i-a should be closed")
	require.Len(t, m1.Loose, 1, "m1 loose = %+v", m1.Loose)
	assert.Equal(t, "i-b", m1.Loose[0].ID)

	m2 := d.Milestones[1]
	assert.Equal(t, "m2", m2.Name)
	assert.Equal(t, 1, m2.Total)
	assert.Equal(t, 0, m2.Done)
	assert.Empty(t, m2.Heads)
	assert.Empty(t, m2.Epics)
	require.Len(t, m2.Loose, 1, "m2 hierarchy = %+v", m2)
	assert.Equal(t, "i-c", m2.Loose[0].ID)
}

// TestMilestonesEmpty: a beads DB with no milestone labels still produces a
// (empty) rollup rather than erroring — the tab renders an empty state.
func TestMilestonesEmpty(t *testing.T) {
	d, err := BuildMilestones(context.Background(), beadsFixture(), "main")
	require.NoError(t, err)
	assert.Empty(t, d.Milestones, "expected no milestones")
	assert.Equal(t, d.Total, d.Unlabeled,
		"with no milestone labels all issues are unlabeled")
}

A beads/model.go => beads/model.go +241 -0
@@ 0,0 1,241 @@
package beads

import (
	"strconv"
	"strings"
)

// --- view model --------------------------------------------------------------

// BeadsData is the opaque .Data value handed to beads.html. Mode discriminates
// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
// detail whose issue is an epic, which also carries its subtask rollup).
type BeadsData struct {
	Mode string // "board" | "detail" | "epic"

	// board mode
	Lanes      []BeadsLane
	Counts     BeadsCounts
	Total      int                // issues placed on the board (after filtering)
	Truncated  bool               // an input table exceeded Max and was clipped
	ShownOf    int                // when Truncated: the reported table total
	Filter     BeadsFilter        // active board filters (sticky form state)
	FilterOpts BeadsFilterOptions // distinct values for the filter dropdowns

	// detail / epic modes
	Issue        *BeadIssue
	DependsOn    []BeadEdge     // this issue depends on … (outgoing, direct)
	DependedOnBy []BeadEdge     // … is depended on by this issue (incoming, direct)
	Comments     []BeadComment  // the comment thread (Comments tab)
	History      []BeadActivity // comments + audit events, time-sorted (History tab)

	// Transitive dependency trees (flattened, pre-order with Depth), shown only
	// when they reach past the direct edges. DependsTree is the full prerequisite
	// chain; DependentTree is everything this issue transitively unblocks.
	DependsTree   []BeadTreeNode
	DependentTree []BeadTreeNode

	// epic mode: the issue's parent-child children and their rollup.
	Subtasks     []BeadSubtask
	SubtaskDone  int // # of subtasks in the closed category
	SubtaskTotal int // len(Subtasks); the progress denominator
}

// BeadsFilter holds the active board filters, parsed from the query string and
// echoed back into the form so selections stick across submits. Empty fields
// mean "no constraint".
type BeadsFilter struct {
	Query    string // substring match over id + title (case-insensitive)
	Type     string // exact issue_type
	Priority string // exact priority ("0".."3")
	Assignee string // exact assignee
	Label    string // issue must carry this label
	Ready    bool   // only actionable-now issues (bd's `ready` set)
}

// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f BeadsFilter) Active() bool {
	return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready
}

// matches reports whether one issue row passes every set filter.
func (f BeadsFilter) matches(id string, row []string, cols map[string]int, labels []string) bool {
	if f.Type != "" && cell(cols, row, "issue_type") != f.Type {
		return false
	}
	if f.Priority != "" && cell(cols, row, "priority") != f.Priority {
		return false
	}
	if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee {
		return false
	}
	if f.Label != "" && !containsString(labels, f.Label) {
		return false
	}
	if f.Query != "" {
		hay := strings.ToLower(id + " " + cell(cols, row, "title"))
		if !strings.Contains(hay, strings.ToLower(f.Query)) {
			return false
		}
	}
	return true
}

// BeadsFilterOptions lists the distinct values present across all issues, so the
// filter dropdowns offer only real choices. Collected from the unfiltered set so
// the options don't shrink as a filter narrows the board.
type BeadsFilterOptions struct {
	Types      []string
	Priorities []string // "0".."3"
	Assignees  []string
	Labels     []string
}

// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
	Name   string // human label, e.g. "Rolling"
	Slug   string // css-safe identifier, e.g. "rolling"
	Accent string // hex accent color for the lane header/border
	Issues []BeadCard
}

// BeadsCounts is the marquee: per-lane totals plus the grand total.
type BeadsCounts struct {
	Rolling   int
	LinedUp   int
	Stalled   int
	PastStand int
	Total     int
}

// BeadCard is one issue as it appears on the board.
type BeadCard struct {
	ID        string
	Title     string
	Type      string
	Priority  string // as stored ("0".."3", ""); PriorityLabel derives the pill
	Assignee  string
	Labels    []string
	BlockedBy int    // # of deps this issue has (things it waits on)
	Blocks    int    // # of deps pointing at this issue (things waiting on it)
	Ready     bool   // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
	Category  string // open | in_progress | closed (used by the milestones view)
}

// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
// or "" when unset/unparseable so the template can omit the marker.
func (c BeadCard) PriorityLabel() string {
	if c.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(c.Priority); err != nil {
		return ""
	}
	return "P" + c.Priority
}

// BeadEdge is one dependency edge to another issue, linked in the detail pane.
type BeadEdge struct {
	IssueID string
	Title   string
	Type    string
	Status  string
	Closed  bool
}

// BeadTreeNode is one node in a flattened transitive dependency tree. Depth is
// the indentation level (0 = a direct edge of the root issue); Type is the
// dependency type of the edge that reached this node.
type BeadTreeNode struct {
	ID     string
	Title  string
	Type   string
	Status string
	Closed bool
	Depth  int
}

// BeadComment is one row of the comments thread.
type BeadComment struct {
	Author    string
	Text      string
	CreatedAt string
}

// BeadActivity is one entry in the merged history timeline: either a comment or
// an audit event from the events table. Summary is a human-readable one-liner
// ("changed status to in_progress"); Text carries the comment body or an event's
// free-text note. Kind drives the icon/label in the template.
type BeadActivity struct {
	Kind      string // "comment" | "event"
	Event     string // events only: the event_type (created/status_changed/updated/closed/…)
	Actor     string
	Summary   string
	Text      string
	CreatedAt string
}

// BeadSubtask is one child of an epic — the "from" side of a parent-child
// dependency that points at the epic. Category (open/in_progress/closed) drives
// the status accent and feeds the epic's progress rollup.
type BeadSubtask struct {
	ID       string
	Title    string
	Status   string
	Category string
	Priority string
	Assignee string
	Blocked  bool
}

// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
// or "" when unset/unparseable.
func (s BeadSubtask) PriorityLabel() string {
	if s.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(s.Priority); err != nil {
		return ""
	}
	return "P" + s.Priority
}

// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
// width. Zero subtasks reads as 0%.
func (d *BeadsData) SubtaskPct() int {
	if d.SubtaskTotal == 0 {
		return 0
	}
	return d.SubtaskDone * 100 / d.SubtaskTotal
}

// BeadIssue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full
// timestamp trail, and the close reason recorded when an issue is resolved.
type BeadIssue struct {
	ID                 string
	Title              string
	Status             string
	Lane               string
	Accent             string
	Priority           string
	IssueType          string
	Assignee           string
	CreatedBy          string
	Owner              string
	EstimatedMinutes   string
	ExternalRef        string
	SpecID             string
	Description        string
	Design             string
	AcceptanceCriteria string
	Notes              string
	CreatedAt          string
	StartedAt          string
	UpdatedAt          string
	ClosedAt           string
	CloseReason        string
	Labels             []string
}

A beads/rows.go => beads/rows.go +87 -0
@@ 0,0 1,87 @@
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
}

// cell returns the named column's value for a row, or "" when the column is
// absent, out of range, or the literal browse NULL placeholder.
func cell(cols map[string]int, row []string, name string) string {
	i, ok := cols[name]
	if !ok || i < 0 || i >= len(row) {
		return ""
	}
	v := row[i]
	if v == "NULL" {
		return ""
	}
	return v
}

// 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
}

M web/beads.go => web/beads.go +12 -1054
@@ 2,14 2,9 @@ package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)


@@ 18,6 13,11 @@ import (
// four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a
// per-issue detail pane reachable via ?issue=<id>. All data is read through the
// BrowseSession surface (Rows/Tables) — there is no SQL engine behind it.
//
// The reading itself is not here: the fingerprint, the lane bucketing, the ready
// rule and the whole view model live in the beads package, which the MCP surface
// shares. This type is only the View adapter — slug, label, template, and the
// hand-off of the request's ref and query.
type beadsView struct{}

func init() { RegisterView(&beadsView{}) }


@@ 26,1058 26,16 @@ func (*beadsView) Name() string     { return "beads" }
func (*beadsView) Label() string    { return "Beads" }
func (*beadsView) Template() string { return "beads.html" }

// beadsMax caps how many rows of any single table the view 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 beadsMax = 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 (*beadsView) 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
}

// --- view model --------------------------------------------------------------

// BeadsData is the opaque .Data value handed to beads.html. Mode discriminates
// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
// detail whose issue is an epic, which also carries its subtask rollup).
type BeadsData struct {
	Mode string // "board" | "detail" | "epic"

	// board mode
	Lanes      []BeadsLane
	Counts     BeadsCounts
	Total      int  // issues placed on the board (after filtering)
	Truncated  bool // an input table exceeded beadsMax and was clipped
	ShownOf    int  // when Truncated: the reported table total
	Filter     BeadsFilter        // active board filters (sticky form state)
	FilterOpts BeadsFilterOptions // distinct values for the filter dropdowns

	// detail / epic modes
	Issue        *BeadIssue
	DependsOn    []BeadEdge     // this issue depends on … (outgoing, direct)
	DependedOnBy []BeadEdge     // … is depended on by this issue (incoming, direct)
	Comments     []BeadComment  // the comment thread (Comments tab)
	History      []BeadActivity // comments + audit events, time-sorted (History tab)

	// Transitive dependency trees (flattened, pre-order with Depth), shown only
	// when they reach past the direct edges. DependsTree is the full prerequisite
	// chain; DependentTree is everything this issue transitively unblocks.
	DependsTree   []BeadTreeNode
	DependentTree []BeadTreeNode

	// epic mode: the issue's parent-child children and their rollup.
	Subtasks     []BeadSubtask
	SubtaskDone  int // # of subtasks in the closed category
	SubtaskTotal int // len(Subtasks); the progress denominator
}

// BeadsFilter holds the active board filters, parsed from the query string and
// echoed back into the form so selections stick across submits. Empty fields
// mean "no constraint".
type BeadsFilter struct {
	Query    string // substring match over id + title (case-insensitive)
	Type     string // exact issue_type
	Priority string // exact priority ("0".."3")
	Assignee string // exact assignee
	Label    string // issue must carry this label
	Ready    bool   // only actionable-now issues (bd's `ready` set)
}

// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f BeadsFilter) Active() bool {
	return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready
}

// matches reports whether one issue row passes every set filter.
func (f BeadsFilter) matches(id string, row []string, cols map[string]int, labels []string) bool {
	if f.Type != "" && cell(cols, row, "issue_type") != f.Type {
		return false
	}
	if f.Priority != "" && cell(cols, row, "priority") != f.Priority {
		return false
	}
	if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee {
		return false
	}
	if f.Label != "" && !containsString(labels, f.Label) {
		return false
	}
	if f.Query != "" {
		hay := strings.ToLower(id + " " + cell(cols, row, "title"))
		if !strings.Contains(hay, strings.ToLower(f.Query)) {
			return false
		}
	}
	return true
}

// BeadsFilterOptions lists the distinct values present across all issues, so the
// filter dropdowns offer only real choices. Collected from the unfiltered set so
// the options don't shrink as a filter narrows the board.
type BeadsFilterOptions struct {
	Types      []string
	Priorities []string // "0".."3"
	Assignees  []string
	Labels     []string
}

// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
	Name   string // human label, e.g. "Rolling"
	Slug   string // css-safe identifier, e.g. "rolling"
	Accent string // hex accent color for the lane header/border
	Issues []BeadCard
}

// BeadsCounts is the marquee: per-lane totals plus the grand total.
type BeadsCounts struct {
	Rolling   int
	LinedUp   int
	Stalled   int
	PastStand int
	Total     int
}

// BeadCard is one issue as it appears on the board.
type BeadCard struct {
	ID        string
	Title     string
	Type      string
	Priority  string // as stored ("0".."3", ""); PriorityLabel derives the pill
	Assignee  string
	Labels    []string
	BlockedBy int    // # of deps this issue has (things it waits on)
	Blocks    int    // # of deps pointing at this issue (things waiting on it)
	Ready     bool   // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
	Category  string // open | in_progress | closed (used by the milestones view)
}

// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
// or "" when unset/unparseable so the template can omit the marker.
func (c BeadCard) PriorityLabel() string {
	if c.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(c.Priority); err != nil {
		return ""
	}
	return "P" + c.Priority
}

// BeadEdge is one dependency edge to another issue, linked in the detail pane.
type BeadEdge struct {
	IssueID string
	Title   string
	Type    string
	Status  string
	Closed  bool
}

// BeadTreeNode is one node in a flattened transitive dependency tree. Depth is
// the indentation level (0 = a direct edge of the root issue); Type is the
// dependency type of the edge that reached this node.
type BeadTreeNode struct {
	ID     string
	Title  string
	Type   string
	Status string
	Closed bool
	Depth  int
}

// depTreeMaxDepth / depTreeMaxNodes bound the transitive walk so a dense or
// cyclic graph can never blow up a detail page.
const (
	depTreeMaxDepth = 6
	depTreeMaxNodes = 200
)

// BeadComment is one row of the comments thread.
type BeadComment struct {
	Author    string
	Text      string
	CreatedAt string
}

// BeadActivity is one entry in the merged history timeline: either a comment or
// an audit event from the events table. Summary is a human-readable one-liner
// ("changed status to in_progress"); Text carries the comment body or an event's
// free-text note. Kind drives the icon/label in the template.
type BeadActivity struct {
	Kind      string // "comment" | "event"
	Event     string // events only: the event_type (created/status_changed/updated/closed/…)
	Actor     string
	Summary   string
	Text      string
	CreatedAt string
}

// BeadSubtask is one child of an epic — the "from" side of a parent-child
// dependency that points at the epic. Category (open/in_progress/closed) drives
// the status accent and feeds the epic's progress rollup.
type BeadSubtask struct {
	ID       string
	Title    string
	Status   string
	Category string
	Priority string
	Assignee string
	Blocked  bool
}

// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
// or "" when unset/unparseable.
func (s BeadSubtask) PriorityLabel() string {
	if s.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(s.Priority); err != nil {
		return ""
	}
	return "P" + s.Priority
}

// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
// width. Zero subtasks reads as 0%.
func (d *BeadsData) SubtaskPct() int {
	if d.SubtaskTotal == 0 {
		return 0
	}
	return d.SubtaskDone * 100 / d.SubtaskTotal
}

// BeadIssue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full
// timestamp trail, and the close reason recorded when an issue is resolved.
type BeadIssue struct {
	ID                 string
	Title              string
	Status             string
	Lane               string
	Accent             string
	Priority           string
	IssueType          string
	Assignee           string
	CreatedBy          string
	Owner              string
	EstimatedMinutes   string
	ExternalRef        string
	SpecID             string
	Description        string
	Design             string
	AcceptanceCriteria string
	Notes              string
	CreatedAt          string
	StartedAt          string
	UpdatedAt          string
	ClosedAt           string
	CloseReason        string
	Labels             []string
}

// --- build -------------------------------------------------------------------
// Applies fingerprints a beads DB; see beads.Applies for the rule.
func (*beadsView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) }

// Build reads the issue graph and produces either the board or, when ?issue=
// names an issue, that issue's detail pane.
func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
	issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
	if err != nil {
		return nil, err
	}
	deps, depsTotal, err := readRows(ctx, sess, ref, "dependencies")
// names an issue, that issue's detail pane. The result is a *beads.BeadsData,
// handed to beads.html as its .Data.
func (*beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, query url.Values) (any, error) {
	data, err := beads.Build(ctx, sess, ref, query)
	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 > beadsMax || depsTotal > beadsMax
	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 v.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 := BeadsFilter{
		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 []BeadCard
	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 := BeadCard{
			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 [][]BeadCard{rolling, linedUp, stalled, pastStand} {
		sortCards(lane, created)
	}

	data := &BeadsData{
		Mode: "board",
		Lanes: []BeadsLane{
			// 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: BeadsCounts{
			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 (v *beadsView) 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,
) *BeadsData {
	// 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 := &BeadsData{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 = &BeadIssue{
		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) BeadEdge {
		st := statusByIssue[id]
		return BeadEdge{
			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 := BeadSubtask{
					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, BeadComment{Author: author, Text: text, CreatedAt: at})
			data.History = append(data.History, BeadActivity{
				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, BeadActivity{
				Kind:      "event",
				Event:     et,
				Actor:     cell(ecols, r, "actor"),
				Summary:   summary,
				Text:      text,
				CreatedAt: cell(ecols, r, "created_at"),
			})
		}
	}

	sortActivity(data.History)
	return data
}

// --- helpers -----------------------------------------------------------------

// readRows reads up to beadsMax 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, beadsMax)
	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, beadsMax)
	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
}

// cell returns the named column's value for a row, or "" when the column is
// absent, out of range, or the literal browse NULL placeholder.
func cell(cols map[string]int, row []string, name string) string {
	i, ok := cols[name]
	if !ok || i < 0 || i >= len(row) {
		return ""
	}
	v := row[i]
	if v == "NULL" {
		return ""
	}
	return v
}

// 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
}

// 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"
	}
}

// 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 []BeadCard, 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 []BeadSubtask) {
	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 []BeadActivity) {
	sort.SliceStable(acts, func(i, j int) bool {
		return acts[i].CreatedAt < acts[j].CreatedAt
	})
}

// humanizeEvent turns one audit row into a readable summary line (and optional
// body text). status_changed / updated carry a JSON new_value fragment
// ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
// markers, with closed's new_value holding the free-text close reason; label
// events keep their whole story in the comment note, so they collapse to a
// single summary line rather than a "label added" header + redundant body.
func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) {
	note = strings.TrimSpace(note)
	switch strings.ToLower(strings.TrimSpace(eventType)) {
	case "created":
		return "created the issue", ""
	case "closed":
		// new_value is the close reason (plain text), not JSON; older rows put it
		// in the note instead.
		if r := strings.TrimSpace(newVal); r != "" {
			return "closed the issue", r
		}
		return "closed the issue", note
	case "status_changed":
		if s := jsonField(newVal, "status"); s != "" {
			return "changed status to " + s, ""
		}
		return "changed status", ""
	case "updated":
		if pairs := jsonPairs(newVal); pairs != "" {
			return "updated " + pairs, ""
		}
		return "updated the issue", ""
	case "label_added":
		return labelLine(note, "added"), ""
	case "label_removed":
		return labelLine(note, "removed"), ""
	default:
		et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
		if et == "" {
			et = "changed"
		}
		return et, note
	}
}

// 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) BeadsFilterOptions {
	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 BeadsFilterOptions{
		Types:      sortedKeys(types),
		Priorities: sortedKeys(prios), // single digits sort numerically as strings
		Assignees:  sortedKeys(assignees),
		Labels:     sortedKeys(labels),
	}
}

// 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
}

// depLink is one outgoing edge in a dependency adjacency map: the neighbor id
// and the edge's dependency type.
type depLink struct {
	to  string
	typ string
}

// buildDepTree walks the adjacency from root (exclusive) breadth-consistent
// pre-order, flattening the reachable set into indented nodes. Each issue
// appears once (first path wins); depth and node count are bounded so a dense
// or cyclic graph is safe.
func buildDepTree(root string, adj map[string][]depLink, titleOf, statusOf, catByStatus map[string]string) []BeadTreeNode {
	var out []BeadTreeNode
	visited := map[string]bool{root: true}
	var dfs func(id string, depth int)
	dfs = func(id string, depth int) {
		if depth > depTreeMaxDepth || len(out) >= depTreeMaxNodes {
			return
		}
		for _, lnk := range adj[id] {
			if visited[lnk.to] || len(out) >= depTreeMaxNodes {
				continue
			}
			visited[lnk.to] = true
			st := statusOf[lnk.to]
			out = append(out, BeadTreeNode{
				ID:     lnk.to,
				Title:  titleOf[lnk.to],
				Type:   lnk.typ,
				Status: st,
				Closed: statusCategory(st, catByStatus) == "closed",
				Depth:  depth,
			})
			dfs(lnk.to, depth+1)
		}
	}
	dfs(root, 0)
	return out
}

// hasTransitive reports whether a flattened tree reaches past the direct edges
// (any Depth>0 node) — the signal that it adds something the flat list doesn't.
func hasTransitive(nodes []BeadTreeNode) bool {
	for _, n := range nodes {
		if n.Depth > 0 {
			return true
		}
	}
	return false
}

// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks
// linked under an epic — still appear on the timeline. Returns ok=false when the
// edge does not touch `want` or the row has no timestamp (older schema without
// created_at: skip rather than emit a blank-dated entry).
func depActivity(want, from, to, typ, at, by string) (BeadActivity, bool) {
	if at == "" || (from != want && to != want) {
		return BeadActivity{}, false
	}
	var summary string
	switch strings.ToLower(strings.TrimSpace(typ)) {
	case "parent-child":
		if to == want {
			summary = "added subtask " + from // want is the epic/parent
		} else {
			summary = "added under epic " + to // want is the child
		}
	case "blocks":
		if from == want {
			summary = "added dependency on " + to
		} else {
			summary = from + " now depends on this"
		}
	case "related":
		// Related is symmetric; emit once (from the issue_id side) to avoid a
		// duplicate entry on both endpoints.
		if from != want {
			return BeadActivity{}, false
		}
		summary = "linked " + to + " (related)"
	default:
		if from == want {
			summary = "added " + typ + " dependency on " + to
		} else {
			return BeadActivity{}, false
		}
	}
	return BeadActivity{Kind: "dep", Event: "dependency", Actor: by, Summary: summary, CreatedAt: at}, true
}

// labelLine collapses a label event to one line. The note reads "Added label:
// <name>"; we drop everything up to the FIRST colon (the "Added label:" prefix)
// and keep the rest, so a namespaced label like "milestone:m3" survives intact
// and the summary becomes "added label milestone:m3".
func labelLine(note, verb string) string {
	name := note
	if i := strings.Index(name, ":"); i >= 0 {
		name = name[i+1:]
	}
	name = strings.TrimSpace(name)
	if name == "" {
		return verb + " a label"
	}
	return verb + " label " + name
}

// jsonField extracts one string-ish field from a JSON object fragment, or ""
// when the value is not a JSON object or the key is absent.
func jsonField(raw, key string) string {
	m := decodeJSONObject(raw)
	if m == nil {
		return ""
	}
	if v, ok := m[key]; ok {
		return scalarString(v)
	}
	return ""
}

// jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
// "updated …" summary. Keys are sorted for a deterministic line.
func jsonPairs(raw string) string {
	m := decodeJSONObject(raw)
	if len(m) == 0 {
		return ""
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, k+" to "+scalarString(m[k]))
	}
	return strings.Join(parts, ", ")
}

// decodeJSONObject parses raw into a map, tolerating the browse NULL placeholder
// and non-object payloads (returns nil rather than erroring).
func decodeJSONObject(raw string) map[string]any {
	raw = strings.TrimSpace(raw)
	if raw == "" || raw == "NULL" {
		return nil
	}
	var m map[string]any
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}
	return m
}

// scalarString renders a decoded JSON scalar the way a person would read it:
// integers without a trailing ".0", everything else via fmt.
func scalarString(v any) string {
	switch t := v.(type) {
	case string:
		return t
	case float64:
		if t == float64(int64(t)) {
			return strconv.FormatInt(int64(t), 10)
		}
		return strconv.FormatFloat(t, 'g', -1, 64)
	case bool:
		if t {
			return "true"
		}
		return "false"
	case nil:
		return ""
	default:
		return fmt.Sprintf("%v", t)
	}
}

M web/beads_test.go => web/beads_test.go +57 -484
@@ 1,10 1,7 @@
package web

import (
	"context"
	"net/http"
	"net/url"
	"sort"
	"strings"
	"testing"



@@ 12,9 9,14 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// The projection these tests drive — the fingerprint, the lanes, the ready rule,
// the detail/epic assembly — is tested in the beads package. What is left here
// is what only this package can answer: that beads.html renders what the
// projection produced, over the real router and template set.

// --- fixtures ----------------------------------------------------------------

// beadsTables is a schema fingerprint that Applies should accept: issues (with
// beadsTables is a schema fingerprint the beads view should accept: issues (with
// id + status) + dependencies both present.
func beadsTables() []browse.TableInfo {
	return []browse.TableInfo{


@@ 32,13 34,8 @@ func beadsTables() []browse.TableInfo {
//   - i-done   : closed              → Past Stand
//   - i-blocked: open, blocked by i-open (a "blocks" dep to a non-closed target)
//     and also carries is_blocked=1  → Stalled
//
// The issues page deliberately orders its columns id,title,status,priority,...
// with is_blocked LAST so column-name mapping (not positional) is exercised.
func beadsFixture() *fakeSession {
	issues := &browse.RowPage{
		// Column order chosen so nothing is at a "natural" index; is_blocked is last
		// and close_reason sits mid-row so name (not positional) mapping is exercised.
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "closed_at", "close_reason", "is_blocked"},
		Rows: [][]string{
			{"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "NULL", "NULL", "0"},


@@ 49,7 46,6 @@ func beadsFixture() *fakeSession {
		Total: 4,
	}
	// i-blocked depends on i-open (blocks, target open → keeps it Stalled).
	// i-open is depended on by i-blocked → i-open.Blocks == 1.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{


@@ 105,334 101,66 @@ func beadsFixture() *fakeSession {
	}
}

// laneBySlug finds a lane in a built board by its slug.
func laneBySlug(d *BeadsData, slug string) *BeadsLane {
	for i := range d.Lanes {
		if d.Lanes[i].Slug == slug {
			return &d.Lanes[i]
		}
	}
	return nil
}

// cardIDs lists the ids of a lane's cards.
func cardIDs(l *BeadsLane) []string {
	if l == nil {
		return nil
	}
	out := make([]string, len(l.Issues))
	for i, c := range l.Issues {
		out[i] = c.ID
	}
	return out
}

// --- Applies -----------------------------------------------------------------

func TestBeadsApplies(t *testing.T) {
	v := &beadsView{}
	if !v.Applies(beadsTables()) {
		t.Fatalf("Applies should be true when issues+dependencies (with id+status) present")
	}
	// Missing dependencies → not a beads DB.
	if v.Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}, {Name: "status"}}},
	}) {
		t.Fatalf("Applies should be false without a dependencies table")
	}
	// issues present but lacking status column → guard rejects.
	if v.Applies([]browse.TableInfo{
		{Name: "issues", Columns: []browse.ColumnInfo{{Name: "id"}}},
		{Name: "dependencies"},
	}) {
		t.Fatalf("Applies should be false when issues lacks a status column")
	}
	// Unrelated schema.
	if v.Applies([]browse.TableInfo{{Name: "widgets"}}) {
		t.Fatalf("Applies should be false for an unrelated schema")
	}
}

// --- board mode --------------------------------------------------------------

func TestBeadsBuildBoardLanes(t *testing.T) {
	v := &beadsView{}
	got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "alice", Name: "db"}, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d, ok := got.(*BeadsData)
	if !ok {
		t.Fatalf("Build returned %T, want *BeadsData", got)
	}
	if d.Mode != "board" {
		t.Fatalf("Mode = %q, want board", d.Mode)
	}

	checks := map[string][]string{
		"rolling":    {"i-prog"},
		"lined-up":   {"i-open"},
		"stalled":    {"i-blocked"},
		"past-stand": {"i-done"},
	}
	for slug, want := range checks {
		got := cardIDs(laneBySlug(d, slug))
		if strings.Join(got, ",") != strings.Join(want, ",") {
			t.Errorf("lane %s = %v, want %v", slug, got, want)
		}
	}

	if d.Counts.Rolling != 1 || d.Counts.LinedUp != 1 || d.Counts.Stalled != 1 || d.Counts.PastStand != 1 {
		t.Errorf("counts = %+v, want 1 each", d.Counts)
	}
	if d.Counts.Total != 4 || d.Total != 4 {
		t.Errorf("total = %d/%d, want 4", d.Counts.Total, d.Total)
	}
}

func TestBeadsBuildBoardCounts(t *testing.T) {
	v := &beadsView{}
	got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	d := got.(*BeadsData)

	// i-blocked depends on i-open → i-blocked.BlockedBy==1, i-open.Blocks==1.
	blocked := laneBySlug(d, "stalled").Issues[0]
	if blocked.ID != "i-blocked" || blocked.BlockedBy != 1 || blocked.Blocks != 0 {
		t.Errorf("i-blocked = %+v, want BlockedBy=1 Blocks=0", blocked)
	}
	open := laneBySlug(d, "lined-up").Issues[0]
	if open.ID != "i-open" || open.Blocks != 1 || open.BlockedBy != 0 {
		t.Errorf("i-open = %+v, want Blocks=1 BlockedBy=0", open)
	}
	// Labels attach by issue_id.
	if strings.Join(open.Labels, ",") != "backend,urgent" {
		t.Errorf("i-open labels = %v, want [backend urgent]", open.Labels)
	}
}

// boardIDs returns every card id on the board, across all lanes.
func boardIDs(d *BeadsData) []string {
	var out []string
	for i := range d.Lanes {
		out = append(out, cardIDs(&d.Lanes[i])...)
	}
	sort.Strings(out)
	return out
}

func TestBeadsBoardFilters(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	cases := []struct {
		name  string
		query url.Values
		want  []string // sorted ids expected on the board
	}{
		{"type", url.Values{"type": {"feature"}}, []string{"i-blocked", "i-open"}},
		{"priority", url.Values{"priority": {"1"}}, []string{"i-blocked", "i-open"}},
		{"assignee", url.Values{"assignee": {"bob"}}, []string{"i-prog"}},
		{"label", url.Values{"label": {"urgent"}}, []string{"i-open"}},
		{"query-title", url.Values{"q": {"ready"}}, []string{"i-open"}},
		{"query-id", url.Values{"q": {"i-done"}}, []string{"i-done"}},
		{"combined-empty", url.Values{"type": {"feature"}, "assignee": {"bob"}}, nil},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, err := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", tc.query)
			if err != nil {
				t.Fatalf("Build: %v", err)
			}
			d := got.(*BeadsData)
			ids := boardIDs(d)
			if strings.Join(ids, ",") != strings.Join(tc.want, ",") {
				t.Errorf("board ids = %v, want %v", ids, tc.want)
			}
			if d.Total != len(tc.want) || d.Counts.Total != len(tc.want) {
				t.Errorf("total = %d/%d, want %d", d.Total, d.Counts.Total, len(tc.want))
			}
			if !d.Filter.Active() {
				t.Errorf("Filter.Active() = false, want true")
			}
		})
	}
}

func TestBeadsBoardFilterOptions(t *testing.T) {
	got, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	d := got.(*BeadsData)
	o := d.FilterOpts
	if strings.Join(o.Types, ",") != "bug,chore,feature" {
		t.Errorf("types = %v", o.Types)
	}
	if strings.Join(o.Priorities, ",") != "0,1,2" {
		t.Errorf("priorities = %v", o.Priorities)
	}
	if strings.Join(o.Assignees, ",") != "alice,bob,carol,dave" {
		t.Errorf("assignees = %v", o.Assignees)
	}
	if strings.Join(o.Labels, ",") != "backend,urgent" {
		t.Errorf("labels = %v", o.Labels)
	}
	if d.Filter.Active() {
		t.Errorf("no query set, Filter.Active() should be false")
	}
}

func TestBeadsReady(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	got, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", url.Values{})
	d := got.(*BeadsData)

	ready := map[string]bool{}
	for i := range d.Lanes {
		for _, c := range d.Lanes[i].Issues {
			ready[c.ID] = c.Ready
		}
	}
	// Only i-open is actionable: open + unblocked. i-prog is in-progress, i-done
	// closed, i-blocked blocked.
	if !ready["i-open"] {
		t.Errorf("i-open should be ready")
	}
	for _, id := range []string{"i-prog", "i-done", "i-blocked"} {
		if ready[id] {
			t.Errorf("%s should not be ready", id)
		}
	}
	// The ready filter narrows the board to the actionable set.
	f, _ := (&beadsView{}).Build(context.Background(), beadsFixture(), repo, "main", url.Values{"ready": {"1"}})
	if ids := boardIDs(f.(*BeadsData)); strings.Join(ids, ",") != "i-open" {
		t.Errorf("ready filter board = %v, want [i-open]", ids)
	}
}

func TestBeadsDepTree(t *testing.T) {
	repo := &core.Repo{OwnerName: "a", Name: "b"}
	// Chain: a --blocks--> b --blocks--> c (c closed). a depends on b depends on c.
	sess := &fakeSession{
		tables: beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "title", "status"},
				Rows:    [][]string{{"a", "Aye", "open"}, {"b", "Bee", "open"}, {"c", "Cee", "closed"}},
				Total:   3,
			},
			"dependencies": {
				Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
				Rows:    [][]string{{"d1", "a", "b", "blocks"}, {"d2", "b", "c", "blocks"}},
				Total:   2,
			},
// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and audit events on the
// epic, so both the subtask rollup and the merged history reach the template.
func beadsEpicFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
		Rows: [][]string{
			{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
			{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
			{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
			{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
		},
		Total: 4,
	}
	// From a: transitive prerequisites b(0) → c(1); c is closed.
	got, _ := (&beadsView{}).Build(context.Background(), sess, repo, "main", url.Values{"issue": {"a"}})
	d := got.(*BeadsData)
	if len(d.DependsTree) != 2 {
		t.Fatalf("DependsTree = %+v, want [b(0) c(1)]", d.DependsTree)
	}
	if d.DependsTree[0].ID != "b" || d.DependsTree[0].Depth != 0 {
		t.Errorf("node0 = %+v, want b depth 0", d.DependsTree[0])
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
		Rows: [][]string{
			{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
			{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
			{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
		},
		Total: 3,
	}
	if d.DependsTree[1].ID != "c" || d.DependsTree[1].Depth != 1 || !d.DependsTree[1].Closed {
		t.Errorf("node1 = %+v, want c depth 1 closed", d.DependsTree[1])
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
		},
		Total: 3,
	}

	// From the leaf c: transitive dependents b(0) → a(1); no prerequisites.
	gotC, _ := (&beadsView{}).Build(context.Background(), sess, repo, "main", url.Values{"issue": {"c"}})
	dc := gotC.(*BeadsData)
	if len(dc.DependentTree) != 2 || dc.DependentTree[0].ID != "b" || dc.DependentTree[1].ID != "a" {
		t.Fatalf("DependentTree = %+v, want [b(0) a(1)]", dc.DependentTree)
	comments := &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows: [][]string{
			{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
			{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
		},
		Total: 2,
	}
	if len(dc.DependsTree) != 0 {
		t.Errorf("c has no prerequisites; DependsTree = %+v", dc.DependsTree)
	events := &browse.RowPage{
		Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
		Rows: [][]string{
			{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
			{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
			{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
			{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
			{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
		},
		Total: 5,
	}
}

// TestBeadsBlockedByDepOnly proves the dependency-derived block signal works
// even when is_blocked is not set: an issue with a "blocks" dep to a non-closed
// target lands in Stalled; the same dep to a CLOSED target does not.
func TestBeadsBlockedByDepOnly(t *testing.T) {
	sess := &fakeSession{
		tables: beadsTables(),
	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues": {
				Columns: []string{"id", "status", "is_blocked"},
				Rows: [][]string{
					{"a", "open", "0"},   // blocked by open b → Stalled
					{"b", "open", "0"},   // ready → Lined Up
					{"c", "open", "0"},   // "blocked" by closed d → NOT stalled → Lined Up
					{"d", "closed", "0"}, // Past Stand
				},
				Total: 4,
			},
			"dependencies": {
				Columns: []string{"issue_id", "depends_on_issue_id", "type"},
				Rows: [][]string{
					{"a", "b", "blocks"},
					{"c", "d", "blocks"},
				},
				Total: 2,
			},
			"issues":          issues,
			"dependencies":    deps,
			"custom_statuses": statuses,
			"comments":        comments,
			"events":          events,
		},
	}
	v := &beadsView{}
	got, err := v.Build(context.Background(), sess, &core.Repo{OwnerName: "a", Name: "b"}, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*BeadsData)
	if ids := cardIDs(laneBySlug(d, "stalled")); strings.Join(ids, ",") != "a" {
		t.Errorf("stalled = %v, want [a] (blocked by open dep only)", ids)
	}
	if ids := cardIDs(laneBySlug(d, "lined-up")); strings.Join(ids, ",") != "b,c" {
		t.Errorf("lined-up = %v, want [b c] (c's blocker is closed)", ids)
	}
}

// --- detail mode -------------------------------------------------------------

func TestBeadsBuildDetail(t *testing.T) {
	v := &beadsView{}
	q := url.Values{}
	q.Set("issue", "i-open")
	got, err := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*BeadsData)
	if d.Mode != "detail" {
		t.Fatalf("Mode = %q, want detail", d.Mode)
	}
	if d.Issue == nil || d.Issue.ID != "i-open" || d.Issue.Title != "Ready to roll" {
		t.Fatalf("Issue = %+v, want i-open/Ready to roll", d.Issue)
	}
	if strings.Join(d.Issue.Labels, ",") != "backend,urgent" {
		t.Errorf("labels = %v", d.Issue.Labels)
	}
	// i-open is depended on by i-blocked (incoming), and depends on nothing.
	if len(d.DependsOn) != 0 {
		t.Errorf("DependsOn = %v, want none", d.DependsOn)
	}
	if len(d.DependedOnBy) != 1 || d.DependedOnBy[0].IssueID != "i-blocked" {
		t.Errorf("DependedOnBy = %+v, want [i-blocked]", d.DependedOnBy)
	}
	// Only i-open's comment shows in its thread.
	if len(d.Comments) != 1 || d.Comments[0].Author != "alice" || d.Comments[0].Text != "first!" {
		t.Errorf("Comments = %+v, want single alice comment", d.Comments)
	}
}

func TestBeadsBuildDetailOutgoingEdge(t *testing.T) {
	v := &beadsView{}
	q := url.Values{}
	q.Set("issue", "i-blocked")
	got, _ := v.Build(context.Background(), beadsFixture(), &core.Repo{OwnerName: "a", Name: "b"}, "main", q)
	d := got.(*BeadsData)
	if len(d.DependsOn) != 1 || d.DependsOn[0].IssueID != "i-open" || d.DependsOn[0].Title != "Ready to roll" {
		t.Fatalf("DependsOn = %+v, want [i-open/Ready to roll]", d.DependsOn)
	}
	if d.DependsOn[0].Type != "blocks" || d.DependsOn[0].Closed {
		t.Errorf("edge = %+v, want type=blocks not-closed", d.DependsOn[0])
	}
}

// --- end to end --------------------------------------------------------------


@@ 570,161 298,6 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) {
	}
}

// --- epic mode + history -----------------------------------------------------

// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
func beadsEpicFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
		Rows: [][]string{
			{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
			{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
			{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
			{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
		},
		Total: 4,
	}
	// Each child is the "from" side of a parent-child edge pointing at the epic;
	// created_at/created_by let the view synthesize "added subtask" history.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type", "created_at", "created_by"},
		Rows: [][]string{
			{"d1", "i-c1", "i-epic", "parent-child", "2024-01-01 09:00:00", "Eugene"},
			{"d2", "i-c2", "i-epic", "parent-child", "2024-01-01 09:05:00", "Eugene"},
			{"d3", "i-c3", "i-epic", "parent-child", "2024-01-01 09:10:00", "Eugene"},
		},
		Total: 3,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
		},
		Total: 3,
	}
	comments := &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows: [][]string{
			{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
			{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
		},
		Total: 2,
	}
	events := &browse.RowPage{
		Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
		Rows: [][]string{
			{"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"},
			{"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"},
			{"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"},
			{"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"},
			{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
		},
		Total: 5,
	}
	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"custom_statuses": statuses,
			"comments":        comments,
			"events":          events,
		},
	}
}

func TestBeadsEpicMode(t *testing.T) {
	v := &beadsView{}
	raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	d := raw.(*BeadsData)
	if d.Mode != "epic" {
		t.Fatalf("Mode = %q, want epic", d.Mode)
	}
	if d.SubtaskTotal != 3 || d.SubtaskDone != 1 {
		t.Errorf("rollup = %d/%d, want 1/3", d.SubtaskDone, d.SubtaskTotal)
	}
	if d.SubtaskPct() != 33 {
		t.Errorf("pct = %d, want 33", d.SubtaskPct())
	}
	// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
	gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
	wantIDs := []string{"i-c3", "i-c1", "i-c2"}
	for i := range wantIDs {
		if gotIDs[i] != wantIDs[i] {
			t.Errorf("subtask order = %v, want %v", gotIDs, wantIDs)
			break
		}
	}
	if d.Subtasks[2].Category != "closed" {
		t.Errorf("last subtask category = %q, want closed", d.Subtasks[2].Category)
	}
}

func TestBeadsHistoryMerge(t *testing.T) {
	v := &beadsView{}
	raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	d := raw.(*BeadsData)

	// Only the epic's own comment shows in the Comments tab (not i-c1's).
	if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" {
		t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments)
	}
	// History merges the epic's 1 comment + 4 events + 3 synthesized subtask-add
	// entries (i-c1's own event is excluded), time-sorted.
	if len(d.History) != 8 {
		t.Fatalf("history len = %d, want 8: %+v", len(d.History), d.History)
	}
	for i := 1; i < len(d.History); i++ {
		if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
			t.Errorf("history not time-sorted at %d: %q > %q", i, d.History[i-1].CreatedAt, d.History[i].CreatedAt)
		}
	}
	if d.History[0].Kind != "event" || d.History[0].Summary != "created the issue" {
		t.Errorf("first history = %+v, want created event", d.History[0])
	}
	last := d.History[len(d.History)-1]
	if last.Kind != "comment" || last.Text != "kickoff" {
		t.Errorf("last history = %+v, want the kickoff comment", last)
	}
	// Humanized change lines, and a label event collapsed to one line with no
	// redundant "Added label:" body.
	var sawStatus, sawUpdate, sawLabel, sawSubtask bool
	for _, a := range d.History {
		switch a.Summary {
		case "changed status to in_progress":
			sawStatus = true
		case "updated priority to 0":
			sawUpdate = true
		case "added label milestone:m3":
			sawLabel = true
			if a.Text != "" {
				t.Errorf("label event should have no body, got %q", a.Text)
			}
		case "added subtask i-c1":
			sawSubtask = true
			if a.Kind != "dep" || a.Actor != "Eugene" {
				t.Errorf("subtask-add entry = %+v, want kind=dep actor=Eugene", a)
			}
		}
		if strings.Contains(a.Text, "Added label:") {
			t.Errorf("label note leaked into a history body: %+v", a)
		}
	}
	if !sawStatus || !sawUpdate || !sawLabel || !sawSubtask {
		t.Errorf("history missing lines; status=%v update=%v label=%v subtask=%v",
			sawStatus, sawUpdate, sawLabel, sawSubtask)
	}
}

func TestBeadsEpicViewRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})

M web/milestones.go => web/milestones.go +9 -220
@@ 3,19 3,16 @@ package web
import (
	"context"
	"net/url"
	"sort"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// milestonePrefix marks labels that name a milestone; the view groups by them.
const milestonePrefix = "milestone:"

// milestonesView groups a beads issue DB by its "milestone:<name>" labels and
// shows per-milestone progress with the issues under each. It shares the beads
// fingerprint, so it appears as a companion tab wherever the Beads view does.
// The grouping itself lives in the beads package; this type is the View adapter.
type milestonesView struct{}

func init() { RegisterView(&milestonesView{}) }


@@ 27,223 24,15 @@ func (*milestonesView) Template() string { return "milestones.html" }
// Applies mirrors the beads fingerprint so Milestones and Beads pair up. A
// beads DB that happens to use no milestone labels still gets the tab; it just
// renders an empty state.
func (*milestonesView) Applies(tables []browse.TableInfo) bool {
	return (&beadsView{}).Applies(tables)
}

// MilestoneView is the opaque .Data handed to milestones.html.
type MilestoneView struct {
	Milestones []MilestoneDetail
	Unlabeled  int // issues carrying no milestone label
	Total      int // all issues read
}

// MilestoneDetail is one milestone's rollup and the issues under it, arranged
// as a shallow hierarchy: the milestone-typed issue(s) first, then epics with
// their subtasks nested one level below, then everything else.
type MilestoneDetail struct {
	Name       string // label with the "milestone:" prefix stripped
	Label      string // full label, for filter links back to the board
	Total      int
	Done       int // closed
	InProgress int
	Open       int             // open (or unknown) — the remaining work
	Heads      []BeadCard      // issue_type == "milestone" — the milestone's own issue(s)
	Epics      []MilestoneEpic // epics in the milestone, each with its nested subtasks
	Loose      []BeadCard      // members that are neither heads, epics, nor nested subtasks
}

// MilestoneEpic is an epic inside a milestone together with the milestone
// members nested under it (parent-child edges pointing at the epic).
type MilestoneEpic struct {
	Card     BeadCard
	Done     int // closed children, for the "d/t" rollup on the epic row
	Total    int
	Children []BeadCard
}

// Pct is the milestone's completion percentage (0..100) for the progress bar.
func (m MilestoneDetail) Pct() int {
	if m.Total == 0 {
		return 0
	}
	return m.Done * 100 / m.Total
}
func (*milestonesView) Applies(tables []browse.TableInfo) bool { return beads.Applies(tables) }

// Build reads the issues and their labels, then groups by milestone label. An
// issue with several milestone labels counts under each. Missing labels/statuses
// tables degrade to empty (no milestones), never an error.
func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) {
	issues, _, err := readRows(ctx, sess, ref, "issues")
// Build groups the issues by milestone label; the result is a
// *beads.MilestoneView, handed to milestones.html as its .Data. The view takes
// no query parameters — the board is where filtering happens.
func (*milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, error) {
	data, err := beads.BuildMilestones(ctx, sess, ref)
	if err != nil {
		return nil, err
	}
	labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
	statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
	deps, _, _ := readRowsOptional(ctx, sess, ref, "dependencies")

	// child issue → its parent-child parents; used to nest tasks under epics.
	parentsByChild := map[string][]string{}
	if deps != nil {
		cols := indexCols(deps.Columns)
		for _, r := range deps.Rows {
			if !strings.EqualFold(cell(cols, r, "type"), "parent-child") {
				continue
			}
			child := cell(cols, r, "issue_id")
			parent := cell(cols, r, "depends_on_issue_id")
			if child != "" && parent != "" {
				parentsByChild[child] = append(parentsByChild[child], parent)
			}
		}
	}

	catByStatus := map[string]string{}
	if statuses != nil {
		cols := indexCols(statuses.Columns)
		for _, r := range statuses.Rows {
			if name := cell(cols, r, "name"); name != "" {
				catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category"))
			}
		}
	}

	labelsByIssue := map[string][]string{}
	if labels != nil {
		cols := indexCols(labels.Columns)
		for _, r := range labels.Rows {
			id := cell(cols, r, "issue_id")
			lb := cell(cols, r, "label")
			if id != "" && lb != "" {
				labelsByIssue[id] = append(labelsByIssue[id], lb)
			}
		}
	}

	issueCols := indexCols(issues.Columns)
	byLabel := map[string]*MilestoneDetail{}
	cardsByLabel := map[string][]BeadCard{}
	unlabeled := 0
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		cat := statusCategory(cell(issueCols, r, "status"), catByStatus)
		card := BeadCard{
			ID:       id,
			Title:    cell(issueCols, r, "title"),
			Type:     cell(issueCols, r, "issue_type"),
			Priority: cell(issueCols, r, "priority"),
			Assignee: cell(issueCols, r, "assignee"),
			Category: cat,
		}
		seen := false
		for _, l := range labelsByIssue[id] {
			if !strings.HasPrefix(l, milestonePrefix) {
				continue
			}
			seen = true
			md := byLabel[l]
			if md == nil {
				md = &MilestoneDetail{Name: strings.TrimPrefix(l, milestonePrefix), Label: l}
				byLabel[l] = md
			}
			md.Total++
			switch cat {
			case "closed":
				md.Done++
			case "in_progress":
				md.InProgress++
			default:
				md.Open++
			}
			cardsByLabel[l] = append(cardsByLabel[l], card)
		}
		if !seen {
			unlabeled++
		}
	}

	names := make([]string, 0, len(byLabel))
	for l := range byLabel {
		names = append(names, l)
	}
	sort.Strings(names)
	out := make([]MilestoneDetail, 0, len(names))
	for _, l := range names {
		md := byLabel[l]
		md.arrange(cardsByLabel[l], parentsByChild)
		out = append(out, *md)
	}

	return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil
}

// arrange splits a milestone's member cards into the display hierarchy: heads
// (issue_type "milestone") on top, then epics with the members parent-child'ed
// under them, then the leftovers. Only members of this milestone participate —
// membership stays purely label-based; a child nests only when its epic carries
// the same milestone label.
func (md *MilestoneDetail) arrange(cards []BeadCard, parentsByChild map[string][]string) {
	epicByID := map[string]*MilestoneEpic{}
	var epics []*MilestoneEpic
	for _, c := range cards {
		switch {
		case strings.EqualFold(c.Type, "milestone"):
			md.Heads = append(md.Heads, c)
		case strings.EqualFold(c.Type, "epic"):
			e := &MilestoneEpic{Card: c}
			epicByID[c.ID] = e
			epics = append(epics, e)
		}
	}
	for _, c := range cards {
		if strings.EqualFold(c.Type, "milestone") || strings.EqualFold(c.Type, "epic") {
			continue
		}
		var home *MilestoneEpic
		for _, p := range parentsByChild[c.ID] {
			if e := epicByID[p]; e != nil {
				home = e
				break
			}
		}
		if home == nil {
			md.Loose = append(md.Loose, c)
			continue
		}
		home.Total++
		if c.Category == "closed" {
			home.Done++
		}
		home.Children = append(home.Children, c)
	}

	sortMilestoneCards(md.Heads)
	sortMilestoneCards(md.Loose)
	sort.SliceStable(epics, func(i, j int) bool {
		return milestoneCardLess(epics[i].Card, epics[j].Card)
	})
	for _, e := range epics {
		sortMilestoneCards(e.Children)
		md.Epics = append(md.Epics, *e)
	}
}

// sortMilestoneCards orders a milestone's issues open-work-first (closed sinks to
// the bottom), then by priority, then id — a stable, deterministic order.
func sortMilestoneCards(cards []BeadCard) {
	sort.SliceStable(cards, func(i, j int) bool {
		return milestoneCardLess(cards[i], cards[j])
	})
}

func milestoneCardLess(a, b BeadCard) bool {
	ca, cb := a.Category == "closed", b.Category == "closed"
	if ca != cb {
		return !ca
	}
	pa, pb := priorityRank(a.Priority), priorityRank(b.Priority)
	if pa != pb {
		return pa < pb
	}
	return a.ID < b.ID
	return data, nil
}

M web/milestones_test.go => web/milestones_test.go +3 -62
@@ 1,9 1,7 @@
package web

import (
	"context"
	"net/http"
	"net/url"
	"strings"
	"testing"



@@ 11,6 9,9 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// The grouping itself is tested in the beads package; what is left here is the
// pairing with the Beads tab and that milestones.html renders the rollup.

// milestoneFixture: six issues across two milestones. m1 carries the full
// hierarchy — a milestone-typed head, an epic with one (closed) subtask, and a
// loose bug; m2 has a single loose feature; one issue has no milestone label.


@@ 74,50 75,6 @@ func TestMilestonesApplies(t *testing.T) {
	}
}

func TestMilestonesBuild(t *testing.T) {
	got, err := (&milestonesView{}).Build(context.Background(), milestoneFixture(), nil, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*MilestoneView)

	if len(d.Milestones) != 2 {
		t.Fatalf("milestones = %d, want 2: %+v", len(d.Milestones), d.Milestones)
	}
	if d.Unlabeled != 1 || d.Total != 6 {
		t.Errorf("unlabeled=%d total=%d, want 1 and 6", d.Unlabeled, d.Total)
	}
	m1 := d.Milestones[0]
	if m1.Name != "m1" || m1.Total != 4 || m1.Done != 1 || m1.Open != 3 || m1.Pct() != 25 {
		t.Errorf("m1 = %+v, want name m1 total 4 done 1 open 3 pct 25", m1)
	}
	// Hierarchy: the milestone-typed issue heads the list, the epic nests its
	// subtask, and the bug (whose only dep edge is "blocks") stays loose.
	if len(m1.Heads) != 1 || m1.Heads[0].ID != "i-m" {
		t.Errorf("m1 heads = %+v, want [i-m]", m1.Heads)
	}
	if len(m1.Epics) != 1 || m1.Epics[0].Card.ID != "i-e" {
		t.Fatalf("m1 epics = %+v, want [i-e]", m1.Epics)
	}
	epic := m1.Epics[0]
	if len(epic.Children) != 1 || epic.Children[0].ID != "i-a" || epic.Done != 1 || epic.Total != 1 {
		t.Errorf("epic = %+v, want child i-a and rollup 1/1", epic)
	}
	if epic.Children[0].Category != "closed" {
		t.Errorf("i-a should be closed: %+v", epic.Children[0])
	}
	if len(m1.Loose) != 1 || m1.Loose[0].ID != "i-b" {
		t.Errorf("m1 loose = %+v, want [i-b]", m1.Loose)
	}
	m2 := d.Milestones[1]
	if m2.Name != "m2" || m2.Total != 1 || m2.Done != 0 {
		t.Errorf("m2 = %+v", m2)
	}
	if len(m2.Heads) != 0 || len(m2.Epics) != 0 || len(m2.Loose) != 1 || m2.Loose[0].ID != "i-c" {
		t.Errorf("m2 hierarchy = %+v, want only loose [i-c]", m2)
	}
}

func TestMilestonesRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})


@@ 147,19 104,3 @@ func TestMilestonesRender(t *testing.T) {
		}
	}
}

// TestMilestonesEmpty: a beads DB with no milestone labels still gets the tab,
// showing an empty state rather than erroring.
func TestMilestonesEmpty(t *testing.T) {
	got, err := (&milestonesView{}).Build(context.Background(), beadsFixture(), nil, "main", url.Values{})
	if err != nil {
		t.Fatalf("Build: %v", err)
	}
	d := got.(*MilestoneView)
	if len(d.Milestones) != 0 {
		t.Errorf("expected no milestones, got %+v", d.Milestones)
	}
	if d.Unlabeled != d.Total {
		t.Errorf("with no milestone labels all issues are unlabeled; got %d/%d", d.Unlabeled, d.Total)
	}
}

M web/realdata_test.go => web/realdata_test.go +5 -4
@@ 6,6 6,7 @@ import (
	"os"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-dolt/beads"
	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)



@@ 62,13 63,13 @@ func TestRealBeadsStore(t *testing.T) {
	if err != nil {
		t.Fatalf("board build: %v", err)
	}
	bd := board.(*BeadsData)
	bd := board.(*beads.BeadsData)
	t.Logf("board total=%d types=%v priorities=%v labels=%v",
		bd.Total, bd.FilterOpts.Types, bd.FilterOpts.Priorities, bd.FilterOpts.Labels)
	if len(bd.FilterOpts.Types) > 0 {
		ft := bd.FilterOpts.Types[0]
		filtered, _ := v.Build(ctx, dbh, nil, ref, url.Values{"type": {ft}})
		t.Logf("filter type=%q → %d issues (of %d)", ft, filtered.(*BeadsData).Total, bd.Total)
		t.Logf("filter type=%q → %d issues (of %d)", ft, filtered.(*beads.BeadsData).Total, bd.Total)
	}
	readyN := 0
	for i := range bd.Lanes {


@@ 85,7 86,7 @@ func TestRealBeadsStore(t *testing.T) {
	if err != nil {
		t.Fatalf("milestones build: %v", err)
	}
	ms := msRaw.(*MilestoneView)
	ms := msRaw.(*beads.MilestoneView)
	t.Logf("milestones (%d, %d unlabeled of %d):", len(ms.Milestones), ms.Unlabeled, ms.Total)
	for _, m := range ms.Milestones {
		t.Logf("  %-24s %d/%d done (%d%%), %d heads, %d epics, %d loose",


@@ 105,7 106,7 @@ func TestRealBeadsStore(t *testing.T) {
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	data := raw.(*BeadsData)
	data := raw.(*beads.BeadsData)
	if data.Issue == nil {
		t.Fatalf("issue %q not found", want)
	}