package beads import ( "context" "net/url" "sort" "strconv" "strings" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" ) // --- build ------------------------------------------------------------------- // Build reads the issue graph and produces either the board or, when ?issue= // names an issue, that issue's detail pane. The board is rendered in one of two // layouts, selected by ?layout= (see parseLayout): four lanes, or the one-column // stream in Sections. Both are the same filtered set in the same buckets. func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values) (*Data, 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 > Max || depsTotal > Max shownOf := issuesTotal // status name → category, from custom_statuses (may be empty → heuristics). catByStatus := indexStatusCategories(statuses) // issue id → category, needed to decide whether a blocking target is "open". issueCols := indexCols(issues.Columns) catByIssue := indexIssueCategories(issues, issueCols, catByStatus) // Aggregate dependency edges by issue. depCols := indexCols(deps.Columns) depIdx := indexDeps(deps, catByIssue) // labels: issue_id → [label] labelsByIssue := indexLabels(labels) // Detail mode: a named issue short-circuits the board build. if want := query.Get("issue"); want != "" { return 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 := Filter{ 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 []Card 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")) || depIdx.blockedOpen[id] // "Ready" mirrors bd's ready set; readyRow is the one copy of the rule, // shared with the cross-database ready page. ready := readyRow(cat, blocked, r, issueCols) if filter.Ready && !ready { continue } card := Card{ 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: depIdx.blockedByCount[id], Blocks: depIdx.blocksCount[id], Ready: ready, StartedAt: cell(issueCols, r, "started_at"), ClosedAt: cell(issueCols, r, "closed_at"), } 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 [][]Card{rolling, linedUp, stalled, pastStand} { sortCards(lane, created) } data := &Data{ Mode: "board", Lanes: []Lane{ // 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: Counts{ 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, Layout: parseLayout(query.Get("layout")), Query: query, } // The stream is the lanes just built, re-sorted for a top-to-bottom read — // derived from them rather than bucketed again, so the section counts cannot // drift from the marquee. if data.Layout == LayoutStream { data.Sections = streamSections(data.Lanes, created) } 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 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, ) *Data { // 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 := &Data{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 = &Issue{ 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) Edge { st := statusByIssue[id] return Edge{ 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 := Subtask{ 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, Comment{Author: author, Text: text, CreatedAt: at}) data.History = append(data.History, Activity{ 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, Activity{ Kind: "event", Event: et, Actor: cell(ecols, r, "actor"), Summary: summary, Text: text, CreatedAt: cell(ecols, r, "created_at"), }) } } sortActivity(data.History) return data } // 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) FilterOptions { 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 FilterOptions{ Types: sortedKeys(types), Priorities: sortedKeys(prios), // single digits sort numerically as strings Assignees: sortedKeys(assignees), Labels: sortedKeys(labels), } } // 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 []Card, 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 []Subtask) { 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 []Activity) { sort.SliceStable(acts, func(i, j int) bool { return acts[i].CreatedAt < acts[j].CreatedAt }) }