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