A web/beads.go => web/beads.go +520 -0
@@ 0,0 1,520 @@
+package web
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "sort"
+ "strconv"
+ "strings"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// beadsView renders a "beads" (bd) issue database as a Mardi Gras parade board:
+// four lanes of cards (Rolling / Lined Up / Stalled / Past Stand) plus a
+// per-issue detail pane reachable via ?issue=<id>. All data is read through the
+// BrowseSession surface (Rows/Tables) — there is no SQL engine behind it.
+type beadsView struct{}
+
+func init() { RegisterView(&beadsView{}) }
+
+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 two renderings: "board" (all lanes) or "detail" (one issue).
+type BeadsData struct {
+ Mode string // "board" | "detail"
+
+ // board mode
+ Lanes []BeadsLane
+ Counts BeadsCounts
+ Total int // total issues placed on the board
+ Truncated bool // an input table exceeded beadsMax and was clipped
+ ShownOf int // when Truncated: the reported table total
+
+ // detail mode
+ Issue *BeadIssue
+ DependsOn []BeadEdge // this issue depends on … (outgoing)
+ DependedOnBy []BeadEdge // … is depended on by this issue (incoming)
+ Comments []BeadComment
+}
+
+// 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)
+}
+
+// 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
+}
+
+// BeadComment is one row of the comments thread.
+type BeadComment struct {
+ Author string
+ Text string
+ CreatedAt string
+}
+
+// BeadIssue is the full issue shown in the detail pane.
+type BeadIssue struct {
+ ID string
+ Title string
+ Status string
+ Lane string
+ Accent string
+ Priority string
+ IssueType string
+ Assignee string
+ CreatedBy string
+ Owner string
+ Description string
+ Design string
+ AcceptanceCriteria string
+ Notes string
+ CreatedAt string
+ UpdatedAt string
+ ClosedAt string
+ Labels []string
+}
+
+// --- build -------------------------------------------------------------------
+
+// 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")
+ 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" || typ == "parent-child") {
+ // Blocked only while the thing it waits on is not yet closed.
+ 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: bucket every issue into exactly one lane.
+ var rolling, linedUp, stalled, pastStand []BeadCard
+ for _, r := range issues.Rows {
+ id := cell(issueCols, r, "id")
+ 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],
+ }
+ cat := catByIssue[id]
+ blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]
+
+ 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{
+ {Name: "Rolling", Slug: "rolling", Accent: "#f5c518", Issues: rolling},
+ {Name: "Lined Up", Slug: "lined-up", Accent: "#2e9e4f", Issues: linedUp},
+ {Name: "Stalled", Slug: "stalled", Accent: "#7b2ff7", Issues: stalled},
+ {Name: "Past Stand", Slug: "past-stand", Accent: "#8a8d91", Issues: pastStand},
+ },
+ Counts: BeadsCounts{
+ Rolling: len(rolling),
+ LinedUp: len(linedUp),
+ Stalled: len(stalled),
+ PastStand: len(pastStand),
+ Total: len(issues.Rows),
+ },
+ Total: len(issues.Rows),
+ Truncated: truncated,
+ ShownOf: shownOf,
+ }
+ return data, nil
+}
+
+// buildDetail assembles the single-issue view: the issue's own fields, its
+// dependency edges in both directions (target title/status resolved), and its
+// comments thread.
+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) for edge labels.
+ titleByIssue := map[string]string{}
+ statusByIssue := map[string]string{}
+ 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")
+ 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
+ }
+
+ 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"),
+ 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"),
+ UpdatedAt: cell(issueCols, row, "updated_at"),
+ ClosedAt: cell(issueCols, row, "closed_at"),
+ 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))
+ }
+ }
+
+ // Comments are optional; a missing table just yields an empty thread.
+ 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
+ }
+ data.Comments = append(data.Comments, BeadComment{
+ Author: cell(ccols, r, "author"),
+ Text: cell(ccols, r, "text"),
+ CreatedAt: cell(ccols, r, "created_at"),
+ })
+ }
+ }
+
+ 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", "#8a8d91"
+ case "in_progress":
+ return "Rolling", "#f5c518"
+ default:
+ return "Lined Up", "#2e9e4f"
+ }
+}
+
+// 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
+}
A web/beads_test.go => web/beads_test.go +333 -0
@@ 0,0 1,333 @@
+package web
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
+ "sourcecraft.dev/bigbes/sr-ht-dolt/core"
+)
+
+// --- fixtures ----------------------------------------------------------------
+
+// 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.
+ Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
+ Rows: [][]string{
+ {"i-open", "Ready to roll", "open", "1", "feature", "alice", "2024-01-01", "0"},
+ {"i-prog", "Under way", "in_progress", "0", "bug", "bob", "2024-01-02", "0"},
+ {"i-done", "Finished", "closed", "2", "chore", "carol", "2024-01-03", "0"},
+ {"i-blocked", "Waiting", "open", "1", "feature", "dave", "2024-01-04", "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,
+ }
+ return &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ tables: beadsTables(),
+ rowsByTable: map[string]*browse.RowPage{
+ "issues": issues,
+ "dependencies": deps,
+ "labels": labels,
+ "custom_statuses": statuses,
+ "comments": comments,
+ },
+ }
+}
+
+// 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)
+ }
+}
+
+// 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(),
+ 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,
+ },
+ },
+ }
+ 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 --------------------------------------------------------------
+
+func TestBeadsHandleViewBoard(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = beadsFixture()
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads", nil, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("board: got %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"Rolling", "Lined Up", "Stalled", "Past Stand", "Ready to roll", "beads-marquee"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("board body missing %q", want)
+ }
+ }
+ // The Tables tab must remain reachable from the view.
+ if !strings.Contains(body, "/~alice/db/tree/") {
+ t.Errorf("board missing Tables tab link; body=%s", body)
+ }
+}
+
+func TestBeadsHandleViewDetail(t *testing.T) {
+ h := newHarness(t)
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = beadsFixture()
+ setViews(t, h, &beadsView{})
+
+ rec := h.do("GET", "/~alice/db/view/beads?issue=i-blocked", nil, nil)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Waiting") {
+ t.Errorf("detail missing issue title; body=%s", body)
+ }
+ if !strings.Contains(body, "Depends on") || !strings.Contains(body, "i-open") {
+ t.Errorf("detail missing dependency edge; body=%s", body)
+ }
+ if !strings.Contains(body, "Back to the parade") {
+ t.Errorf("detail missing back link; body=%s", body)
+ }
+}
A web/templates/beads.html => web/templates/beads.html +203 -0
@@ 0,0 1,203 @@
+{{define "content" -}}
+<style>
+/* Scoped Mardi Gras palette — inlined here because the scss bundle is not
+ rebuilt in dev. Everything is prefixed .beads so it never leaks. */
+.beads-marquee {
+ display: flex; flex-wrap: wrap; gap: .5rem; align-items: stretch;
+ margin-bottom: 1rem;
+ border-radius: .4rem; overflow: hidden;
+ border: 2px solid #7b2ff7;
+ background: linear-gradient(90deg, #2b0a52 0%, #3a1466 50%, #1f5c34 100%);
+}
+.beads-marquee .bead-stat {
+ flex: 1 1 auto; min-width: 7rem; padding: .5rem .75rem; color: #fdf6e3;
+ text-align: center;
+}
+.beads-marquee .bead-stat .n { display: block; font-size: 1.5rem; font-weight: 700; line-height: 1; }
+.beads-marquee .bead-stat .l { display: block; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; opacity: .85; }
+.beads-marquee .bead-stat.total { background: rgba(245,197,24,.18); }
+.beads-marquee .bead-stat .n.rolling { color: #f5c518; }
+.beads-marquee .bead-stat .n.lined-up { color: #7be0a0; }
+.beads-marquee .bead-stat .n.stalled { color: #c9a3ff; }
+.beads-marquee .bead-stat .n.past-stand { color: #c9ccd1; }
+
+.beads-lanes { display: flex; gap: 1rem; overflow-x: auto; padding-bottom: .5rem; }
+.beads-lane { flex: 1 0 16rem; min-width: 16rem; }
+.beads-lane .lane-head {
+ display: flex; justify-content: space-between; align-items: center;
+ padding: .4rem .6rem; border-radius: .35rem .35rem 0 0;
+ color: #fff; font-weight: 600;
+}
+.beads-lane .lane-body {
+ border: 1px solid rgba(0,0,0,.12); border-top: none;
+ border-radius: 0 0 .35rem .35rem; padding: .5rem;
+ background: rgba(123,47,247,.03); min-height: 4rem;
+}
+.beads-lane .lane-count {
+ background: rgba(255,255,255,.25); border-radius: 1rem;
+ padding: 0 .5rem; font-size: .8rem;
+}
+.bead-card {
+ display: block; background: #fff; border: 1px solid rgba(0,0,0,.12);
+ border-left: 4px solid #7b2ff7; border-radius: .3rem;
+ padding: .5rem .6rem; margin-bottom: .5rem; text-decoration: none; color: inherit;
+}
+.bead-card:hover { border-color: #7b2ff7; box-shadow: 0 1px 4px rgba(123,47,247,.25); text-decoration: none; }
+.bead-card .card-id { font-family: monospace; font-size: .75rem; color: #7b2ff7; }
+.bead-card .card-title { display: block; font-weight: 600; margin: .1rem 0 .25rem; }
+.bead-card .card-meta { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }
+.bead-pill {
+ font-size: .68rem; padding: .05rem .4rem; border-radius: 1rem;
+ border: 1px solid rgba(0,0,0,.15); background: #f3f3f3;
+}
+.bead-pill.prio { background: #f5c518; border-color: #d9ad0a; color: #3a2d00; font-weight: 700; }
+.bead-pill.type { background: #ece3ff; border-color: #cbb6ff; color: #46248a; }
+.bead-pill.assignee { background: #e3f4ea; border-color: #a7d9bd; color: #1d5c37; }
+.bead-pill.label { background: #f0f0f0; }
+.bead-pill.block { background: #ffe9e0; border-color: #f0b49b; color: #8a3311; }
+.beads-empty { color: #888; font-size: .85rem; font-style: italic; padding: .25rem; }
+
+.bead-detail .field-label { font-weight: 600; color: #46248a; }
+.bead-detail pre.field-body {
+ white-space: pre-wrap; background: #faf7ff; border: 1px solid #e6dbff;
+ border-radius: .3rem; padding: .5rem; margin: .25rem 0 1rem;
+}
+.bead-comment {
+ border-left: 3px solid #2e9e4f; background: #f5faf7;
+ padding: .4rem .6rem; margin-bottom: .5rem; border-radius: .2rem;
+}
+.bead-comment .c-head { font-size: .8rem; color: #1d5c37; margin-bottom: .2rem; }
+.bead-comment .c-body { white-space: pre-wrap; }
+@media (prefers-color-scheme: dark) {
+ .bead-card { background: #1e1e24; border-color: #333; color: #e6e6e6; }
+ .bead-detail pre.field-body { background: #1e1626; border-color: #3a2a50; color: #e6e6e6; }
+ .bead-comment { background: #16211a; }
+ .bead-pill { background: #2a2a30; border-color: #444; color: #ddd; }
+}
+</style>
+
+<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> · parade</h2>
+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}}
+
+{{if eq .Data.Mode "detail"}}
+{{/* ---------------- detail pane ---------------- */}}
+<p><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">← Back to the parade</a></p>
+{{with .Data.Issue}}
+<div class="bead-detail">
+ <h3>
+ <code>{{.ID}}</code> {{.Title}}
+ <span class="bead-pill" style="border-color: {{.Accent}}; color: {{.Accent}};">{{.Lane}}</span>
+ </h3>
+ <div class="card-meta mb-3">
+ <span class="bead-pill">status: {{.Status}}</span>
+ {{if .Priority}}<span class="bead-pill prio">P{{.Priority}}</span>{{end}}
+ {{if .IssueType}}<span class="bead-pill type">{{.IssueType}}</span>{{end}}
+ {{if .Assignee}}<span class="bead-pill assignee">@{{.Assignee}}</span>{{end}}
+ {{range .Labels}}<span class="bead-pill label">{{.}}</span>{{end}}
+ </div>
+ <table class="table table-sm">
+ <tbody>
+ {{if .CreatedBy}}<tr><td class="field-label">Created by</td><td>{{.CreatedBy}}</td></tr>{{end}}
+ {{if .Owner}}<tr><td class="field-label">Owner</td><td>{{.Owner}}</td></tr>{{end}}
+ {{if .CreatedAt}}<tr><td class="field-label">Created</td><td>{{.CreatedAt}}</td></tr>{{end}}
+ {{if .UpdatedAt}}<tr><td class="field-label">Updated</td><td>{{.UpdatedAt}}</td></tr>{{end}}
+ {{if .ClosedAt}}<tr><td class="field-label">Closed</td><td>{{.ClosedAt}}</td></tr>{{end}}
+ </tbody>
+ </table>
+ {{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}
+ {{if .Design}}<div class="field-label">Design</div><pre class="field-body">{{.Design}}</pre>{{end}}
+ {{if .AcceptanceCriteria}}<div class="field-label">Acceptance criteria</div><pre class="field-body">{{.AcceptanceCriteria}}</pre>{{end}}
+ {{if .Notes}}<div class="field-label">Notes</div><pre class="field-body">{{.Notes}}</pre>{{end}}
+</div>
+
+<div class="row">
+ <div class="col-md-6">
+ <h4>Depends on</h4>
+ {{if $.Data.DependsOn}}
+ <ul>
+ {{range $.Data.DependsOn}}
+ <li>
+ <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.IssueID | urlquery}}"><code>{{.IssueID}}</code></a>
+ {{if .Title}}— {{.Title}}{{end}}
+ <span class="bead-pill">{{.Type}}</span>
+ {{if .Closed}}<span class="bead-pill">closed</span>{{else}}<span class="bead-pill block">{{.Status}}</span>{{end}}
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="beads-empty">No outgoing dependencies.</p>{{end}}
+ </div>
+ <div class="col-md-6">
+ <h4>Depended on by</h4>
+ {{if $.Data.DependedOnBy}}
+ <ul>
+ {{range $.Data.DependedOnBy}}
+ <li>
+ <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.IssueID | urlquery}}"><code>{{.IssueID}}</code></a>
+ {{if .Title}}— {{.Title}}{{end}}
+ <span class="bead-pill">{{.Type}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{else}}<p class="beads-empty">Nothing depends on this issue.</p>{{end}}
+ </div>
+</div>
+
+<h4>Comments</h4>
+{{if $.Data.Comments}}
+{{range $.Data.Comments}}
+<div class="bead-comment">
+ <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}· {{.CreatedAt}}{{end}}</div>
+ <div class="c-body">{{.Text}}</div>
+</div>
+{{end}}
+{{else}}<p class="beads-empty">No comments.</p>{{end}}
+{{else}}
+<div class="alert alert-warning">Issue not found.</div>
+{{end}}
+
+{{else}}
+{{/* ---------------- board ---------------- */}}
+{{if .Data.Truncated}}
+<div class="alert alert-warning">Showing the first {{.Data.Total}} of {{.Data.ShownOf}} issues.</div>
+{{end}}
+
+<div class="beads-marquee">
+ <div class="bead-stat"><span class="n rolling">{{.Data.Counts.Rolling}}</span><span class="l">Rolling</span></div>
+ <div class="bead-stat"><span class="n lined-up">{{.Data.Counts.LinedUp}}</span><span class="l">Lined Up</span></div>
+ <div class="bead-stat"><span class="n stalled">{{.Data.Counts.Stalled}}</span><span class="l">Stalled</span></div>
+ <div class="bead-stat"><span class="n past-stand">{{.Data.Counts.PastStand}}</span><span class="l">Past Stand</span></div>
+ <div class="bead-stat total"><span class="n">{{.Data.Counts.Total}}</span><span class="l">Total</span></div>
+</div>
+
+<div class="beads-lanes">
+ {{range .Data.Lanes}}
+ {{$accent := .Accent}}
+ <div class="beads-lane">
+ <div class="lane-head" style="background: {{.Accent}};">
+ <span>{{.Name}}</span>
+ <span class="lane-count">{{len .Issues}}</span>
+ </div>
+ <div class="lane-body">
+ {{range .Issues}}
+ <a class="bead-card" style="border-left-color: {{$accent}};"
+ href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
+ <span class="card-id">{{.ID}}</span>
+ <span class="card-title">{{.Title}}</span>
+ <span class="card-meta">
+ {{if .PriorityLabel}}<span class="bead-pill prio">{{.PriorityLabel}}</span>{{end}}
+ {{if .Type}}<span class="bead-pill type">{{.Type}}</span>{{end}}
+ {{if .Assignee}}<span class="bead-pill assignee">@{{.Assignee}}</span>{{end}}
+ {{range .Labels}}<span class="bead-pill label">{{.}}</span>{{end}}
+ {{if .BlockedBy}}<span class="bead-pill block">🚧 {{.BlockedBy}}</span>{{end}}
+ {{if .Blocks}}<span class="bead-pill">blocks {{.Blocks}}</span>{{end}}
+ </span>
+ </a>
+ {{else}}
+ <p class="beads-empty">Empty lane.</p>
+ {{end}}
+ </div>
+ </div>
+ {{end}}
+</div>
+{{end}}
+{{- end}}