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=<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 renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
// detail whose issue is an epic, which also carries its subtask rollup).
type BeadsData struct {
Mode string // "board" | "detail" | "epic"
// board mode
Lanes []BeadsLane
Counts BeadsCounts
Total int // issues placed on the board (after filtering)
Truncated bool // an input table exceeded beadsMax and was clipped
ShownOf int // when Truncated: the reported table total
Filter BeadsFilter // active board filters (sticky form state)
FilterOpts BeadsFilterOptions // distinct values for the filter dropdowns
// detail / epic modes
Issue *BeadIssue
DependsOn []BeadEdge // this issue depends on … (outgoing, direct)
DependedOnBy []BeadEdge // … is depended on by this issue (incoming, direct)
Comments []BeadComment // the comment thread (Comments tab)
History []BeadActivity // comments + audit events, time-sorted (History tab)
// Transitive dependency trees (flattened, pre-order with Depth), shown only
// when they reach past the direct edges. DependsTree is the full prerequisite
// chain; DependentTree is everything this issue transitively unblocks.
DependsTree []BeadTreeNode
DependentTree []BeadTreeNode
// epic mode: the issue's parent-child children and their rollup.
Subtasks []BeadSubtask
SubtaskDone int // # of subtasks in the closed category
SubtaskTotal int // len(Subtasks); the progress denominator
}
// BeadsFilter holds the active board filters, parsed from the query string and
// echoed back into the form so selections stick across submits. Empty fields
// mean "no constraint".
type BeadsFilter struct {
Query string // substring match over id + title (case-insensitive)
Type string // exact issue_type
Priority string // exact priority ("0".."3")
Assignee string // exact assignee
Label string // issue must carry this label
Ready bool // only actionable-now issues (bd's `ready` set)
}
// Active reports whether any filter is set (drives the "Clear" link and the
// empty-board wording).
func (f BeadsFilter) Active() bool {
return f.Query != "" || f.Type != "" || f.Priority != "" || f.Assignee != "" || f.Label != "" || f.Ready
}
// matches reports whether one issue row passes every set filter.
func (f BeadsFilter) matches(id string, row []string, cols map[string]int, labels []string) bool {
if f.Type != "" && cell(cols, row, "issue_type") != f.Type {
return false
}
if f.Priority != "" && cell(cols, row, "priority") != f.Priority {
return false
}
if f.Assignee != "" && cell(cols, row, "assignee") != f.Assignee {
return false
}
if f.Label != "" && !containsString(labels, f.Label) {
return false
}
if f.Query != "" {
hay := strings.ToLower(id + " " + cell(cols, row, "title"))
if !strings.Contains(hay, strings.ToLower(f.Query)) {
return false
}
}
return true
}
// BeadsFilterOptions lists the distinct values present across all issues, so the
// filter dropdowns offer only real choices. Collected from the unfiltered set so
// the options don't shrink as a filter narrows the board.
type BeadsFilterOptions struct {
Types []string
Priorities []string // "0".."3"
Assignees []string
Labels []string
}
// BeadsLane is one parade lane and the cards in it.
type BeadsLane struct {
Name string // human label, e.g. "Rolling"
Slug string // css-safe identifier, e.g. "rolling"
Accent string // hex accent color for the lane header/border
Issues []BeadCard
}
// BeadsCounts is the marquee: per-lane totals plus the grand total.
type BeadsCounts struct {
Rolling int
LinedUp int
Stalled int
PastStand int
Total int
}
// BeadCard is one issue as it appears on the board.
type BeadCard struct {
ID string
Title string
Type string
Priority string // as stored ("0".."3", ""); PriorityLabel derives the pill
Assignee string
Labels []string
BlockedBy int // # of deps this issue has (things it waits on)
Blocks int // # of deps pointing at this issue (things waiting on it)
Ready bool // actionable now: open, unblocked, not deferred/template (bd's `ready` set)
Category string // open | in_progress | closed (used by the milestones view)
}
// PriorityLabel renders the numeric priority as a P-pill label ("P0".."P3"),
// or "" when unset/unparseable so the template can omit the marker.
func (c BeadCard) PriorityLabel() string {
if c.Priority == "" {
return ""
}
if _, err := strconv.Atoi(c.Priority); err != nil {
return ""
}
return "P" + c.Priority
}
// BeadEdge is one dependency edge to another issue, linked in the detail pane.
type BeadEdge struct {
IssueID string
Title string
Type string
Status string
Closed bool
}
// BeadTreeNode is one node in a flattened transitive dependency tree. Depth is
// the indentation level (0 = a direct edge of the root issue); Type is the
// dependency type of the edge that reached this node.
type BeadTreeNode struct {
ID string
Title string
Type string
Status string
Closed bool
Depth int
}
// depTreeMaxDepth / depTreeMaxNodes bound the transitive walk so a dense or
// cyclic graph can never blow up a detail page.
const (
depTreeMaxDepth = 6
depTreeMaxNodes = 200
)
// BeadComment is one row of the comments thread.
type BeadComment struct {
Author string
Text string
CreatedAt string
}
// BeadActivity is one entry in the merged history timeline: either a comment or
// an audit event from the events table. Summary is a human-readable one-liner
// ("changed status to in_progress"); Text carries the comment body or an event's
// free-text note. Kind drives the icon/label in the template.
type BeadActivity struct {
Kind string // "comment" | "event"
Event string // events only: the event_type (created/status_changed/updated/closed/…)
Actor string
Summary string
Text string
CreatedAt string
}
// BeadSubtask is one child of an epic — the "from" side of a parent-child
// dependency that points at the epic. Category (open/in_progress/closed) drives
// the status accent and feeds the epic's progress rollup.
type BeadSubtask struct {
ID string
Title string
Status string
Category string
Priority string
Assignee string
Blocked bool
}
// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
// or "" when unset/unparseable.
func (s BeadSubtask) PriorityLabel() string {
if s.Priority == "" {
return ""
}
if _, err := strconv.Atoi(s.Priority); err != nil {
return ""
}
return "P" + s.Priority
}
// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
// width. Zero subtasks reads as 0%.
func (d *BeadsData) SubtaskPct() int {
if d.SubtaskTotal == 0 {
return 0
}
return d.SubtaskDone * 100 / d.SubtaskTotal
}
// BeadIssue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full
// timestamp trail, and the close reason recorded when an issue is resolved.
type BeadIssue struct {
ID string
Title string
Status string
Lane string
Accent string
Priority string
IssueType string
Assignee string
CreatedBy string
Owner string
EstimatedMinutes string
ExternalRef string
SpecID string
Description string
Design string
AcceptanceCriteria string
Notes string
CreatedAt string
StartedAt string
UpdatedAt string
ClosedAt string
CloseReason string
Labels []string
}
// --- build -------------------------------------------------------------------
// 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" {
// A "blocks" edge to a still-open target blocks the source. parent-child
// is hierarchy, not a blocker — a subtask is not blocked by its (open)
// epic, matching bd's own is_blocked/ready accounting.
if catByIssue[to] != "closed" {
blockedOpen[from] = true
}
}
}
// labels: issue_id → [label]
labelsByIssue := map[string][]string{}
if labels != nil {
lcols := indexCols(labels.Columns)
for _, r := range labels.Rows {
id := cell(lcols, r, "issue_id")
lb := cell(lcols, r, "label")
if id != "" && lb != "" {
labelsByIssue[id] = append(labelsByIssue[id], lb)
}
}
}
// Detail mode: a named issue short-circuits the board build.
if want := query.Get("issue"); want != "" {
return v.buildDetail(ctx, sess, ref, want, issues, issueCols, deps, depCols,
labelsByIssue, catByStatus, catByIssue), nil
}
// Board mode: parse the sticky filters and collect dropdown options from the
// full issue set (options stay stable as filters narrow the board).
filter := BeadsFilter{
Query: strings.TrimSpace(query.Get("q")),
Type: query.Get("type"),
Priority: query.Get("priority"),
Assignee: query.Get("assignee"),
Label: query.Get("label"),
Ready: query.Get("ready") == "1",
}
opts := collectFilterOptions(issues, issueCols, labelsByIssue)
// Bucket every matching issue into exactly one lane.
var rolling, linedUp, stalled, pastStand []BeadCard
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
if !filter.matches(id, r, issueCols, labelsByIssue[id]) {
continue
}
cat := catByIssue[id]
blocked := truthy(cell(issueCols, r, "is_blocked")) || blockedOpen[id]
// "Ready" mirrors bd's ready set: open (not in-progress/closed), unblocked,
// and not a template/ephemeral scaffold. Derived in-process (the issues
// data is already loaded) rather than reading the full ready_issues table.
ready := cat == "open" && !blocked &&
!truthy(cell(issueCols, r, "is_template")) && !truthy(cell(issueCols, r, "ephemeral"))
if filter.Ready && !ready {
continue
}
card := BeadCard{
ID: id,
Title: cell(issueCols, r, "title"),
Type: cell(issueCols, r, "issue_type"),
Priority: cell(issueCols, r, "priority"),
Assignee: cell(issueCols, r, "assignee"),
Labels: labelsByIssue[id],
BlockedBy: blockedByCount[id],
Blocks: blocksCount[id],
Ready: ready,
}
switch {
case cat == "closed":
pastStand = append(pastStand, card)
case cat == "in_progress":
rolling = append(rolling, card)
case blocked:
stalled = append(stalled, card)
default: // open (or unknown) and not blocked
linedUp = append(linedUp, card)
}
}
created := issueCreatedAt(issues, issueCols)
for _, lane := range [][]BeadCard{rolling, linedUp, stalled, pastStand} {
sortCards(lane, created)
}
data := &BeadsData{
Mode: "board",
Lanes: []BeadsLane{
// Accents are muted Mardi Gras hues (gold / green / violet / gray)
// chosen to read on both the light and dark SourceHut themes. They
// are applied by the template as thin accents (card border, lane
// underline, tinted chips), never as body text, so contrast holds.
{Name: "Rolling", Slug: "rolling", Accent: "#c9930a", Issues: rolling},
{Name: "Lined Up", Slug: "lined-up", Accent: "#2f9e44", Issues: linedUp},
{Name: "Stalled", Slug: "stalled", Accent: "#9c36b5", Issues: stalled},
{Name: "Past Stand", Slug: "past-stand", Accent: "#868e96", Issues: pastStand},
},
Counts: BeadsCounts{
Rolling: len(rolling),
LinedUp: len(linedUp),
Stalled: len(stalled),
PastStand: len(pastStand),
Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
},
Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
Truncated: truncated,
ShownOf: shownOf,
Filter: filter,
FilterOpts: opts,
}
return data, nil
}
// buildDetail assembles the single-issue view: the issue's own fields, its
// dependency edges in both directions (target title/status resolved), its
// comments thread, and a merged history timeline. When the issue is an epic
// (issue_type == "epic") it switches to Mode "epic" and also gathers the
// parent-child children as a subtask rollup.
func (v *beadsView) buildDetail(
ctx context.Context, sess BrowseSession, ref, want string,
issues *browse.RowPage, issueCols map[string]int,
deps *browse.RowPage, depCols map[string]int,
labelsByIssue map[string][]string,
catByStatus, catByIssue map[string]string,
) *BeadsData {
// id → (title, status, whole row) for edge labels and the subtask rollup.
titleByIssue := map[string]string{}
statusByIssue := map[string]string{}
rowByID := make(map[string][]string, len(issues.Rows))
var row []string
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
titleByIssue[id] = cell(issueCols, r, "title")
statusByIssue[id] = cell(issueCols, r, "status")
rowByID[id] = r
if id == want {
row = r
}
}
data := &BeadsData{Mode: "detail"}
if row == nil {
// Unknown id: a detail pane with a nil Issue; the template shows a
// "not found" note and a link back to the board.
return data
}
// An epic gets its own rendering mode; the template branches on it to add the
// subtask rollup while reusing the shared detail chrome.
if strings.EqualFold(cell(issueCols, row, "issue_type"), "epic") {
data.Mode = "epic"
}
status := cell(issueCols, row, "status")
name, accent := laneForCategory(statusCategory(status, catByStatus))
data.Issue = &BeadIssue{
ID: want,
Title: cell(issueCols, row, "title"),
Status: status,
Lane: name,
Accent: accent,
Priority: cell(issueCols, row, "priority"),
IssueType: cell(issueCols, row, "issue_type"),
Assignee: cell(issueCols, row, "assignee"),
CreatedBy: cell(issueCols, row, "created_by"),
Owner: cell(issueCols, row, "owner"),
EstimatedMinutes: cell(issueCols, row, "estimated_minutes"),
ExternalRef: cell(issueCols, row, "external_ref"),
SpecID: cell(issueCols, row, "spec_id"),
Description: cell(issueCols, row, "description"),
Design: cell(issueCols, row, "design"),
AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"),
Notes: cell(issueCols, row, "notes"),
CreatedAt: cell(issueCols, row, "created_at"),
StartedAt: cell(issueCols, row, "started_at"),
UpdatedAt: cell(issueCols, row, "updated_at"),
ClosedAt: cell(issueCols, row, "closed_at"),
CloseReason: cell(issueCols, row, "close_reason"),
Labels: labelsByIssue[want],
}
edge := func(id, typ string) BeadEdge {
st := statusByIssue[id]
return BeadEdge{
IssueID: id,
Title: titleByIssue[id],
Type: typ,
Status: st,
Closed: statusCategory(st, catByStatus) == "closed",
}
}
for _, r := range deps.Rows {
from := cell(depCols, r, "issue_id")
to := cell(depCols, r, "depends_on_issue_id")
typ := cell(depCols, r, "type")
if from == want && to != "" {
data.DependsOn = append(data.DependsOn, edge(to, typ))
}
if to == want && from != "" {
data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
// A parent-child edge pointing at this issue makes `from` a subtask,
// but that only matters when this issue is an epic.
if data.Mode == "epic" && strings.EqualFold(typ, "parent-child") {
cr := rowByID[from]
cat := catByIssue[from]
st := BeadSubtask{
ID: from,
Title: titleByIssue[from],
Status: statusByIssue[from],
Category: cat,
Priority: cell(issueCols, cr, "priority"),
Assignee: cell(issueCols, cr, "assignee"),
Blocked: truthy(cell(issueCols, cr, "is_blocked")),
}
data.Subtasks = append(data.Subtasks, st)
data.SubtaskTotal++
if cat == "closed" {
data.SubtaskDone++
}
}
}
// beads logs no event for a dependency/subtask link, but the row records
// created_at/created_by — synthesize a timeline entry so "added subtask X"
// (and other edge additions) appear in History.
if act, ok := depActivity(want, from, to, typ,
cell(depCols, r, "created_at"), cell(depCols, r, "created_by")); ok {
data.History = append(data.History, act)
}
}
sortSubtasks(data.Subtasks)
// Transitive dependency trees over the full edge set. Kept only when they
// reach past the direct edges (a Depth>0 node), so they add the chain the
// flat Depends-on / Depended-on-by lists can't show, without duplicating them.
outAdj := map[string][]depLink{} // id → things it depends on
inAdj := map[string][]depLink{} // id → things that depend on it
for _, r := range deps.Rows {
from := cell(depCols, r, "issue_id")
to := cell(depCols, r, "depends_on_issue_id")
if from == "" || to == "" {
continue
}
typ := cell(depCols, r, "type")
outAdj[from] = append(outAdj[from], depLink{to: to, typ: typ})
inAdj[to] = append(inAdj[to], depLink{to: from, typ: typ})
}
if t := buildDepTree(want, outAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
data.DependsTree = t
}
if t := buildDepTree(want, inAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
data.DependentTree = t
}
// Comments are optional; a missing table just yields an empty thread. Each
// comment is also folded into the merged history timeline below.
if comments, _, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
ccols := indexCols(comments.Columns)
for _, r := range comments.Rows {
if cell(ccols, r, "issue_id") != want {
continue
}
author := cell(ccols, r, "author")
text := cell(ccols, r, "text")
at := cell(ccols, r, "created_at")
data.Comments = append(data.Comments, BeadComment{Author: author, Text: text, CreatedAt: at})
data.History = append(data.History, BeadActivity{
Kind: "comment",
Actor: author,
Summary: "commented",
Text: text,
CreatedAt: at,
})
}
}
// The audit log (events) is optional too; when present it joins the comments
// in the History tab as humanized, time-ordered entries.
if events, _, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
ecols := indexCols(events.Columns)
for _, r := range events.Rows {
if cell(ecols, r, "issue_id") != want {
continue
}
et := cell(ecols, r, "event_type")
summary, text := humanizeEvent(et,
cell(ecols, r, "old_value"), cell(ecols, r, "new_value"), cell(ecols, r, "comment"))
data.History = append(data.History, BeadActivity{
Kind: "event",
Event: et,
Actor: cell(ecols, r, "actor"),
Summary: summary,
Text: text,
CreatedAt: cell(ecols, r, "created_at"),
})
}
}
sortActivity(data.History)
return data
}
// --- helpers -----------------------------------------------------------------
// readRows reads up to beadsMax rows of a required table and its reported total.
func readRows(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) {
page, err := sess.Rows(ctx, ref, table, 0, beadsMax)
if err != nil {
return nil, 0, err
}
return page, page.Total, nil
}
// readRowsOptional is readRows for a table that may not exist: ErrTableNotFound
// degrades to (nil, 0, nil) so the caller can treat it as empty.
func readRowsOptional(ctx context.Context, sess BrowseSession, ref, table string) (*browse.RowPage, int, error) {
page, err := sess.Rows(ctx, ref, table, 0, beadsMax)
if err != nil {
if errors.Is(err, browse.ErrTableNotFound) {
return nil, 0, nil
}
return nil, 0, err
}
return page, page.Total, nil
}
// indexCols builds a column-name → cell-index map from a RowPage's Columns, so
// cells are addressed by name regardless of the underlying column order.
func indexCols(cols []string) map[string]int {
m := make(map[string]int, len(cols))
for i, c := range cols {
m[c] = i
}
return m
}
// cell returns the named column's value for a row, or "" when the column is
// absent, out of range, or the literal browse NULL placeholder.
func cell(cols map[string]int, row []string, name string) string {
i, ok := cols[name]
if !ok || i < 0 || i >= len(row) {
return ""
}
v := row[i]
if v == "NULL" {
return ""
}
return v
}
// truthy reports whether a cell reads as a set boolean/flag.
func truthy(s string) bool {
switch strings.ToLower(strings.TrimSpace(s)) {
case "1", "true", "yes", "t", "y":
return true
}
return false
}
// statusCategory maps a status name to one of open / in_progress / closed. It
// prefers the custom_statuses lookup and falls back to name heuristics when the
// status is unknown there (or the table was empty).
func statusCategory(status string, catByStatus map[string]string) string {
s := strings.ToLower(strings.TrimSpace(status))
if s == "" {
return "open"
}
if cat, ok := catByStatus[s]; ok && cat != "" {
switch cat {
case "in_progress", "closed", "open":
return cat
}
}
switch {
case strings.Contains(s, "progress"), strings.Contains(s, "doing"), strings.Contains(s, "active"), s == "wip":
return "in_progress"
case strings.Contains(s, "close"), strings.Contains(s, "done"), strings.Contains(s, "resolved"), strings.Contains(s, "complete"):
return "closed"
default:
return "open"
}
}
// laneForCategory returns the lane display name and accent for a status
// category (used by the detail pane; the board buckets inline because it also
// needs the blocked signal).
func laneForCategory(cat string) (name, accent string) {
switch cat {
case "closed":
return "Past Stand", "#868e96"
case "in_progress":
return "Rolling", "#c9930a"
default:
return "Lined Up", "#2f9e44"
}
}
// issueCreatedAt maps issue id → created_at string, for lane sorting.
func issueCreatedAt(issues *browse.RowPage, cols map[string]int) map[string]string {
m := make(map[string]string, len(issues.Rows))
for _, r := range issues.Rows {
m[cell(cols, r, "id")] = cell(cols, r, "created_at")
}
return m
}
// sortCards orders a lane by priority (0 = highest first), then created_at
// ascending, then id — a stable, deterministic parade order.
func sortCards(cards []BeadCard, created map[string]string) {
sort.SliceStable(cards, func(i, j int) bool {
pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
if pi != pj {
return pi < pj
}
ci, cj := created[cards[i].ID], created[cards[j].ID]
if ci != cj {
return ci < cj
}
return cards[i].ID < cards[j].ID
})
}
// priorityRank parses a priority to an int for sorting; unset/unparseable sorts
// last (a large rank).
func priorityRank(p string) int {
if p == "" {
return 1 << 30
}
n, err := strconv.Atoi(strings.TrimSpace(p))
if err != nil {
return 1 << 30
}
return n
}
// sortSubtasks orders an epic's children open-work-first: unclosed before
// closed, then by priority (0 highest), then id — closed subtasks sink to the
// bottom so the actionable ones lead.
func sortSubtasks(subs []BeadSubtask) {
sort.SliceStable(subs, func(i, j int) bool {
ci, cj := subs[i].Category == "closed", subs[j].Category == "closed"
if ci != cj {
return !ci // open (false) sorts before closed (true)
}
pi, pj := priorityRank(subs[i].Priority), priorityRank(subs[j].Priority)
if pi != pj {
return pi < pj
}
return subs[i].ID < subs[j].ID
})
}
// sortActivity orders the merged history oldest-first (chronological). Timestamps
// share the "YYYY-MM-DD HH:MM:SS" shape across events and comments, so a lexical
// compare is a time compare; ties fall back to id-free but stable order.
func sortActivity(acts []BeadActivity) {
sort.SliceStable(acts, func(i, j int) bool {
return acts[i].CreatedAt < acts[j].CreatedAt
})
}
// humanizeEvent turns one audit row into a readable summary line (and optional
// body text). status_changed / updated carry a JSON new_value fragment
// ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle
// markers, with closed's new_value holding the free-text close reason; label
// events keep their whole story in the comment note, so they collapse to a
// single summary line rather than a "label added" header + redundant body.
func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) {
note = strings.TrimSpace(note)
switch strings.ToLower(strings.TrimSpace(eventType)) {
case "created":
return "created the issue", ""
case "closed":
// new_value is the close reason (plain text), not JSON; older rows put it
// in the note instead.
if r := strings.TrimSpace(newVal); r != "" {
return "closed the issue", r
}
return "closed the issue", note
case "status_changed":
if s := jsonField(newVal, "status"); s != "" {
return "changed status to " + s, ""
}
return "changed status", ""
case "updated":
if pairs := jsonPairs(newVal); pairs != "" {
return "updated " + pairs, ""
}
return "updated the issue", ""
case "label_added":
return labelLine(note, "added"), ""
case "label_removed":
return labelLine(note, "removed"), ""
default:
et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
if et == "" {
et = "changed"
}
return et, note
}
}
// collectFilterOptions gathers the distinct issue_type / priority / assignee
// values and label names across all issues, sorted, for the filter dropdowns.
func collectFilterOptions(issues *browse.RowPage, cols map[string]int, labelsByIssue map[string][]string) BeadsFilterOptions {
types, prios, assignees, labels := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
for _, r := range issues.Rows {
if t := cell(cols, r, "issue_type"); t != "" {
types[t] = true
}
if p := cell(cols, r, "priority"); p != "" {
prios[p] = true
}
if a := cell(cols, r, "assignee"); a != "" {
assignees[a] = true
}
}
for _, lbs := range labelsByIssue {
for _, l := range lbs {
labels[l] = true
}
}
return BeadsFilterOptions{
Types: sortedKeys(types),
Priorities: sortedKeys(prios), // single digits sort numerically as strings
Assignees: sortedKeys(assignees),
Labels: sortedKeys(labels),
}
}
// sortedKeys returns a set's keys in ascending order.
func sortedKeys(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Strings(out)
return out
}
// containsString reports whether s is in xs.
func containsString(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
// depLink is one outgoing edge in a dependency adjacency map: the neighbor id
// and the edge's dependency type.
type depLink struct {
to string
typ string
}
// buildDepTree walks the adjacency from root (exclusive) breadth-consistent
// pre-order, flattening the reachable set into indented nodes. Each issue
// appears once (first path wins); depth and node count are bounded so a dense
// or cyclic graph is safe.
func buildDepTree(root string, adj map[string][]depLink, titleOf, statusOf, catByStatus map[string]string) []BeadTreeNode {
var out []BeadTreeNode
visited := map[string]bool{root: true}
var dfs func(id string, depth int)
dfs = func(id string, depth int) {
if depth > depTreeMaxDepth || len(out) >= depTreeMaxNodes {
return
}
for _, lnk := range adj[id] {
if visited[lnk.to] || len(out) >= depTreeMaxNodes {
continue
}
visited[lnk.to] = true
st := statusOf[lnk.to]
out = append(out, BeadTreeNode{
ID: lnk.to,
Title: titleOf[lnk.to],
Type: lnk.typ,
Status: st,
Closed: statusCategory(st, catByStatus) == "closed",
Depth: depth,
})
dfs(lnk.to, depth+1)
}
}
dfs(root, 0)
return out
}
// hasTransitive reports whether a flattened tree reaches past the direct edges
// (any Depth>0 node) — the signal that it adds something the flat list doesn't.
func hasTransitive(nodes []BeadTreeNode) bool {
for _, n := range nodes {
if n.Depth > 0 {
return true
}
}
return false
}
// depActivity synthesizes a History entry for a dependency edge touching `want`.
// beads emits no audit event when a link is added, but the dependencies row
// carries created_at/created_by, so edge additions — most usefully subtasks
// linked under an epic — still appear on the timeline. Returns ok=false when the
// edge does not touch `want` or the row has no timestamp (older schema without
// created_at: skip rather than emit a blank-dated entry).
func depActivity(want, from, to, typ, at, by string) (BeadActivity, bool) {
if at == "" || (from != want && to != want) {
return BeadActivity{}, false
}
var summary string
switch strings.ToLower(strings.TrimSpace(typ)) {
case "parent-child":
if to == want {
summary = "added subtask " + from // want is the epic/parent
} else {
summary = "added under epic " + to // want is the child
}
case "blocks":
if from == want {
summary = "added dependency on " + to
} else {
summary = from + " now depends on this"
}
case "related":
// Related is symmetric; emit once (from the issue_id side) to avoid a
// duplicate entry on both endpoints.
if from != want {
return BeadActivity{}, false
}
summary = "linked " + to + " (related)"
default:
if from == want {
summary = "added " + typ + " dependency on " + to
} else {
return BeadActivity{}, false
}
}
return BeadActivity{Kind: "dep", Event: "dependency", Actor: by, Summary: summary, CreatedAt: at}, true
}
// labelLine collapses a label event to one line. The note reads "Added label:
// <name>"; 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)
}
}