From 8f5ae3f0a62b982df3ab607539761b2fbc8b0f8e Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 18 Jul 2026 23:26:57 +0300 Subject: [PATCH] feat(web): Mardi Gras beads view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a beads (bd) issue database as a Mardi Gras parade board: four lanes — Rolling (in progress), Lined Up (open & ready), Stalled (open & blocked), Past Stand (done) — plus a marquee of per-lane counts. Cards show id/title/type/priority/assignee/labels and blocked-by/blocks counts; an issue-detail pane (?issue=) shows the full issue with its dependency edges in both directions and its comments thread. Fingerprints on the issues + dependencies tables and reads everything through the browse layer (column-name-keyed, so column order is irrelevant), capped at 2000 rows/table. Status is bucketed via custom_statuses with name heuristics as a fallback; "blocked" means is_blocked or an open blocking dependency. Plugs into the view registry via init()+RegisterView with no framework edits. Purple/gold/green palette scoped in the template (no scss rebuild needed). --- web/beads.go | 520 +++++++++++++++++++++++++++++++++++++++ web/beads_test.go | 333 +++++++++++++++++++++++++ web/templates/beads.html | 203 +++++++++++++++ 3 files changed, 1056 insertions(+) create mode 100644 web/beads.go create mode 100644 web/beads_test.go create mode 100644 web/templates/beads.html diff --git a/web/beads.go b/web/beads.go new file mode 100644 index 0000000000000000000000000000000000000000..3fa9f7d51367fe7c47056301eeb42e470b8685f0 --- /dev/null +++ b/web/beads.go @@ -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=. 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 +} diff --git a/web/beads_test.go b/web/beads_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a019b766639ce44b8fccdca5d44831cfad1f5eb6 --- /dev/null +++ b/web/beads_test.go @@ -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) + } +} diff --git a/web/templates/beads.html b/web/templates/beads.html new file mode 100644 index 0000000000000000000000000000000000000000..d2bbbef485c8f94478f773f3218b08617d3eeae9 --- /dev/null +++ b/web/templates/beads.html @@ -0,0 +1,203 @@ +{{define "content" -}} + + +

~{{.Repo.OwnerName}}/{{.Repo.Name}} · parade

+{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}} + +{{if eq .Data.Mode "detail"}} +{{/* ---------------- detail pane ---------------- */}} +

← Back to the parade

+{{with .Data.Issue}} +
+

+ {{.ID}} {{.Title}} + {{.Lane}} +

+
+ status: {{.Status}} + {{if .Priority}}P{{.Priority}}{{end}} + {{if .IssueType}}{{.IssueType}}{{end}} + {{if .Assignee}}@{{.Assignee}}{{end}} + {{range .Labels}}{{.}}{{end}} +
+ + + {{if .CreatedBy}}{{end}} + {{if .Owner}}{{end}} + {{if .CreatedAt}}{{end}} + {{if .UpdatedAt}}{{end}} + {{if .ClosedAt}}{{end}} + +
Created by{{.CreatedBy}}
Owner{{.Owner}}
Created{{.CreatedAt}}
Updated{{.UpdatedAt}}
Closed{{.ClosedAt}}
+ {{if .Description}}
Description
{{.Description}}
{{end}} + {{if .Design}}
Design
{{.Design}}
{{end}} + {{if .AcceptanceCriteria}}
Acceptance criteria
{{.AcceptanceCriteria}}
{{end}} + {{if .Notes}}
Notes
{{.Notes}}
{{end}} +
+ +
+
+

Depends on

+ {{if $.Data.DependsOn}} +
    + {{range $.Data.DependsOn}} +
  • + {{.IssueID}} + {{if .Title}}— {{.Title}}{{end}} + {{.Type}} + {{if .Closed}}closed{{else}}{{.Status}}{{end}} +
  • + {{end}} +
+ {{else}}

No outgoing dependencies.

{{end}} +
+
+

Depended on by

+ {{if $.Data.DependedOnBy}} +
    + {{range $.Data.DependedOnBy}} +
  • + {{.IssueID}} + {{if .Title}}— {{.Title}}{{end}} + {{.Type}} +
  • + {{end}} +
+ {{else}}

Nothing depends on this issue.

{{end}} +
+
+ +

Comments

+{{if $.Data.Comments}} +{{range $.Data.Comments}} +
+
{{.Author}} {{if .CreatedAt}}· {{.CreatedAt}}{{end}}
+
{{.Text}}
+
+{{end}} +{{else}}

No comments.

{{end}} +{{else}} +
Issue not found.
+{{end}} + +{{else}} +{{/* ---------------- board ---------------- */}} +{{if .Data.Truncated}} +
Showing the first {{.Data.Total}} of {{.Data.ShownOf}} issues.
+{{end}} + +
+
{{.Data.Counts.Rolling}}Rolling
+
{{.Data.Counts.LinedUp}}Lined Up
+
{{.Data.Counts.Stalled}}Stalled
+
{{.Data.Counts.PastStand}}Past Stand
+
{{.Data.Counts.Total}}Total
+
+ + +{{end}} +{{- end}}