From f9c82b0b52a29d95365455e3a975ca9e3608ef2b Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Wed, 12 Aug 2026 23:27:39 +0300 Subject: [PATCH] beads: extract the projection out of web/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit web/beads.go held the one reading of the beads schema — the table fingerprint, the lane bucketing, the ready rule, the status categories, the transitive dependency walk, the event humanizer, the filter model and the milestone rollup — where a second consumer could not reach it. The MCP surface and the cross-database ready page both need it. Move it to a new root package beads/ that depends on browse/ and the standard library only: rows in, view model out, no net/http, no html/template, no core. The BrowseSession seam is declared consumer-side there and names the one method the projections call, so web's larger BrowseSession satisfies it structurally and a session passes straight through. web/beads.go and web/milestones.go keep only their View adapters — slug, label, template, Applies, and the hand-off of ref and query. The templates are unchanged: the moved types keep their names and their display methods, so every dot still resolves. Pure move plus the beadsMax -> beads.Max export rename. The projection tests move with the code and become testify; the render tests stay in web/ unchanged. Same 63 tests pass before and after. --- beads/beads.go | 116 +++++ beads/beads_test.go | 493 ++++++++++++++++++ beads/build.go | 463 +++++++++++++++++ beads/deps.go | 102 ++++ beads/events.go | 135 +++++ beads/milestones.go | 227 ++++++++ beads/milestones_test.go | 111 ++++ beads/model.go | 241 +++++++++ beads/rows.go | 87 ++++ web/beads.go | 1066 +------------------------------------- web/beads_test.go | 541 ++----------------- web/milestones.go | 229 +------- web/milestones_test.go | 65 +-- web/realdata_test.go | 9 +- 14 files changed, 2061 insertions(+), 1824 deletions(-) create mode 100644 beads/beads.go create mode 100644 beads/beads_test.go create mode 100644 beads/build.go create mode 100644 beads/deps.go create mode 100644 beads/events.go create mode 100644 beads/milestones.go create mode 100644 beads/milestones_test.go create mode 100644 beads/model.go create mode 100644 beads/rows.go diff --git a/beads/beads.go b/beads/beads.go new file mode 100644 index 0000000000000000000000000000000000000000..eefd1e8e63d82f91642ffe4168f131b5ed21f53a --- /dev/null +++ b/beads/beads.go @@ -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" + } +} diff --git a/beads/beads_test.go b/beads/beads_test.go new file mode 100644 index 0000000000000000000000000000000000000000..fbc6da4d33b6e9d108493adbbc51adf49f78f6d0 --- /dev/null +++ b/beads/beads_test.go @@ -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") +} diff --git a/beads/build.go b/beads/build.go new file mode 100644 index 0000000000000000000000000000000000000000..d7d59861962ffed6beb747088a9700a2ae259054 --- /dev/null +++ b/beads/build.go @@ -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 + }) +} diff --git a/beads/deps.go b/beads/deps.go new file mode 100644 index 0000000000000000000000000000000000000000..1d3d0984bb48610a1cef7c6233a696417a8d5a57 --- /dev/null +++ b/beads/deps.go @@ -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 +} diff --git a/beads/events.go b/beads/events.go new file mode 100644 index 0000000000000000000000000000000000000000..95bf4649b6bd4322d96e2eecfd94c621794df7c0 --- /dev/null +++ b/beads/events.go @@ -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: +// "; 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) + } +} diff --git a/beads/milestones.go b/beads/milestones.go new file mode 100644 index 0000000000000000000000000000000000000000..b69e1cb1a5b8bf316ee529a093a3c694d972d6dd --- /dev/null +++ b/beads/milestones.go @@ -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 +} diff --git a/beads/milestones_test.go b/beads/milestones_test.go new file mode 100644 index 0000000000000000000000000000000000000000..24c098126079e357bf32cdd397543d8f3d679d49 --- /dev/null +++ b/beads/milestones_test.go @@ -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") +} diff --git a/beads/model.go b/beads/model.go new file mode 100644 index 0000000000000000000000000000000000000000..6e5e3b5e88145726b9679f036a70b4bf66ba0f9e --- /dev/null +++ b/beads/model.go @@ -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 +} diff --git a/beads/rows.go b/beads/rows.go new file mode 100644 index 0000000000000000000000000000000000000000..4d15489fdbf705ae9f68a19b90e366931b1d6dec --- /dev/null +++ b/beads/rows.go @@ -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 +} diff --git a/web/beads.go b/web/beads.go index 212c320d421a31bdb7d92bc521be3f6c963d9eeb..54923d79abc499f03aecab8f7526a94b6012a212 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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=. 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: -// "; 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) - } -} diff --git a/web/beads_test.go b/web/beads_test.go index aea87e9187c96d70790813b6c8dae0d72a075ab8..766fa6277ab7e415774043a8a3fa2b587f97771e 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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}) diff --git a/web/milestones.go b/web/milestones.go index eb8d5a285bb7524469b4917df9f1e685d088d3fc..a86adb9b389ca9b86de2df18171938ce07bdcb12 100644 --- a/web/milestones.go +++ b/web/milestones.go @@ -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:" 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 } diff --git a/web/milestones_test.go b/web/milestones_test.go index fc5f4c8e8aa4a544e10691985ef99554658e285e..6c8b968874843d289242067ebc9e4613ead1bdc1 100644 --- a/web/milestones_test.go +++ b/web/milestones_test.go @@ -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) - } -} diff --git a/web/realdata_test.go b/web/realdata_test.go index 20c6bef955e050f19f975f65203f0a282d0274f9..77dfc145156e8f856723d38d6fe60d82c66d7fbb 100644 --- a/web/realdata_test.go +++ b/web/realdata_test.go @@ -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) }