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. 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 ------------------------------------------------------------------- // 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{ // 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(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"), 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)) } } // 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", "#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 }