package web import ( "context" "encoding/json" "errors" "fmt" "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 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 // 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 / epic modes Issue *BeadIssue DependsOn []BeadEdge // this issue depends on … (outgoing) DependedOnBy []BeadEdge // … is depended on by this issue (incoming) Comments []BeadComment // the comment thread (Comments tab) History []BeadActivity // comments + audit events, time-sorted (History tab) // 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 } // 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 } // 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 ------------------------------------------------------------------- // 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), 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++ } } } } sortSubtasks(data.Subtasks) // 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 } } // 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) } }