coverage~bigbes/sr-ht-doltede3b0bbbeads/build.go

Coverage
97.1% 170/175 statements
Δ
Blob
59edbcd
1 package beads
2
3 import (
4 "context"
5 "net/url"
6 "sort"
7 "strconv"
8 "strings"
9
10 "sourcecraft.dev/bigbes/sr-ht-dolt/browse"
11 )
12
13 // --- build -------------------------------------------------------------------
14
15 // Build reads the issue graph and produces either the board or, when ?issue=
16 // names an issue, that issue's detail pane. The board is rendered in one of two
17 // layouts, selected by ?layout= (see parseLayout): four lanes, or the one-column
18 // stream in Sections. Both are the same filtered set in the same buckets.
19 74 func Build(ctx context.Context, sess BrowseSession, ref string, query url.Values) (*Data, error) {
20 74 issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
21 74 if err != nil {
22 0 return nil, err
23 0 }
24 74 deps, depsTotal, err := readRows(ctx, sess, ref, "dependencies")
25 74 if err != nil {
26 0 return nil, err
27 0 }
28 // Optional tables: absent ones degrade to empty rather than failing the view.
29 74 labels, labelsTotal, _ := readRowsOptional(ctx, sess, ref, "labels")
30 74 statuses, statusesTotal, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
31 74
32 74 // The board's flag names the two required tables it buckets from, which is
33 74 // what it has always reported. It stays that, because it is paired with a
34 74 // count line the other tables cannot change; the optional ones are reported
35 74 // beside it, one entry each.
36 74 truncated := issuesTotal > Max || depsTotal > Max
37 74 shownOf := issuesTotal
38 74
39 74 // Every table the board draws from, in read order, with what a clipped read
40 74 // of it costs the board. clipsOver keeps the ones that were actually clipped
41 74 // and drops the rest, so a board over complete reads carries an empty list.
42 74 // The totals are all in hand from the reads just made; naming a table here
43 74 // costs no second read.
44 74 clipped := clipsOver(
45 74 ClippedTable{Table: "issues", Total: issuesTotal,
46 74 Effect: "the lanes below hold only the rows that were read"},
47 74 ClippedTable{Table: "dependencies", Total: depsTotal,
48 74 Effect: "blocked and blocking counts are short, and a card standing in Lined Up may belong in Stalled"},
49 74 ClippedTable{Table: "labels", Total: labelsTotal,
50 74 Effect: "cards are missing label pills, and the label filter offers only the labels that were read"},
51 74 ClippedTable{Table: "custom_statuses", Total: statusesTotal,
52 74 Effect: "statuses defined past the cap fall back to name heuristics, so a card may be in the wrong lane"},
53 74 )
54 74
55 74 // status name → category, from custom_statuses (may be empty → heuristics).
56 74 catByStatus := indexStatusCategories(statuses)
57 74
58 74 // issue id → category, needed to decide whether a blocking target is "open".
59 74 issueCols := indexCols(issues.Columns)
60 74 catByIssue := indexIssueCategories(issues, issueCols, catByStatus)
61 74
62 74 // Aggregate dependency edges by issue.
63 74 depCols := indexCols(deps.Columns)
64 74 depIdx := indexDeps(deps, catByIssue)
65 74
66 74 // labels: issue_id → [label]
67 74 labelsByIssue := indexLabels(labels)
68 74
69 74 // Detail mode: a named issue short-circuits the board build.
70 74 if want := query.Get("issue"); want != "" {
71 23 // The detail pane draws on every table read above — its labels come from
72 23 // labels, its lane from custom_statuses — so its clip flag covers all of
73 23 // them, plus the two tables it reads itself. Each total is already in hand;
74 23 // none of this costs a second read.
75 23 clip := readClip{
76 23 truncated: truncated || labelsTotal > Max || statusesTotal > Max,
77 23 issuesTotal: issuesTotal,
78 23 }
79 23 return buildDetail(ctx, sess, ref, want, issues, issueCols, deps, depCols,
80 23 labels, labelsByIssue, catByStatus, catByIssue, clip), nil
81 23 }
82
83 // Board mode: parse the sticky filters and collect dropdown options from the
84 // full issue set (options stay stable as filters narrow the board).
85 51 filter := Filter{
86 51 Query: strings.TrimSpace(query.Get("q")),
87 51 Type: query.Get("type"),
88 51 Priority: query.Get("priority"),
89 51 Assignee: query.Get("assignee"),
90 51 Label: query.Get("label"),
91 51 Ready: query.Get("ready") == "1",
92 51 }
93 51 opts := collectFilterOptions(issues, issueCols, labelsByIssue)
94 51
95 51 // Bucket every matching issue into exactly one lane.
96 51 var rolling, linedUp, stalled, pastStand []Card
97 6470 for _, r := range rowsOf(issues) {
98 6470 id := cell(issueCols, r, "id")
99 6470 if !filter.matches(id, r, issueCols, labelsByIssue[id]) {
100 146 continue
101 }
102 6324 cat := catByIssue[id]
103 6324 blocked := truthy(cell(issueCols, r, "is_blocked")) || depIdx.blockedOpen[id]
104 6324 // "Ready" mirrors bd's ready set; readyRow is the one copy of the rule,
105 6324 // shared with the cross-database ready page.
106 6324 ready := readyRow(cat, blocked, r, issueCols)
107 6324 if filter.Ready && !ready {
108 26 continue
109 }
110 6298 card := Card{
111 6298 ID: id,
112 6298 Title: cell(issueCols, r, "title"),
113 6298 Type: cell(issueCols, r, "issue_type"),
114 6298 Priority: cell(issueCols, r, "priority"),
115 6298 Assignee: cell(issueCols, r, "assignee"),
116 6298 Labels: labelsByIssue[id],
117 6298 BlockedBy: depIdx.blockedByCount[id],
118 6298 Blocks: depIdx.blocksCount[id],
119 6298 Ready: ready,
120 6298 StartedAt: cell(issueCols, r, "started_at"),
121 6298 ClosedAt: cell(issueCols, r, "closed_at"),
122 6298 }
123 6298
124 6298 switch {
125 2061 case cat == "closed":
126 2061 pastStand = append(pastStand, card)
127 2057 case cat == "in_progress":
128 2057 rolling = append(rolling, card)
129 56 case blocked:
130 56 stalled = append(stalled, card)
131 2124 default: // open (or unknown) and not blocked
132 2124 linedUp = append(linedUp, card)
133 }
134 }
135
136 51 created := issueCreatedAt(issues, issueCols)
137 204 for _, lane := range [][]Card{rolling, linedUp, stalled, pastStand} {
138 204 sortCards(lane, created)
139 204 }
140
141 51 data := &Data{
142 51 Mode: "board",
143 51 Lanes: []Lane{
144 51 // Accents are muted Mardi Gras hues (gold / green / violet / gray)
145 51 // chosen to read on both the light and dark SourceHut themes. They
146 51 // are applied by the template as thin accents (card border, lane
147 51 // underline, tinted chips), never as body text, so contrast holds.
148 51 {Name: "Rolling", Slug: "rolling", Accent: "#c9930a", Issues: rolling},
149 51 {Name: "Lined Up", Slug: "lined-up", Accent: "#2f9e44", Issues: linedUp},
150 51 {Name: "Stalled", Slug: "stalled", Accent: "#9c36b5", Issues: stalled},
151 51 {Name: "Past Stand", Slug: "past-stand", Accent: "#868e96", Issues: pastStand},
152 51 },
153 51 Counts: Counts{
154 51 Rolling: len(rolling),
155 51 LinedUp: len(linedUp),
156 51 Stalled: len(stalled),
157 51 PastStand: len(pastStand),
158 51 Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
159 51 },
160 51 Total: len(rolling) + len(linedUp) + len(stalled) + len(pastStand),
161 51 Truncated: truncated,
162 51 ShownOf: shownOf,
163 51 Clipped: clipped,
164 51 Filter: filter,
165 51 FilterOpts: opts,
166 51 Layout: parseLayout(query.Get("layout")),
167 51 Query: query,
168 51 }
169 51 // The stream is the lanes just built, re-sorted for a top-to-bottom read —
170 51 // derived from them rather than bucketed again, so the section counts cannot
171 51 // drift from the marquee.
172 51 if data.Layout == LayoutStream {
173 14 data.Sections = streamSections(data.Lanes, created)
174 14 }
175 51 return data, nil
176 }
177
178 // clipsOver keeps the candidates whose table came back clipped — a reported
179 // total past Max — in the order they were given, and fills in the rows that
180 // were read. A table read whole is not on the list at all: the list is the
181 // clips, not the tables.
182 74 func clipsOver(cands ...ClippedTable) []ClippedTable {
183 74 var out []ClippedTable
184 296 for _, c := range cands {
185 296 if c.Total <= Max {
186 281 continue
187 }
188 15 c.Shown = Max
189 15 out = append(out, c)
190 }
191 74 return out
192 }
193
194 // readClip is what the row reads reported about their own completeness: whether
195 // any table came back clipped at Max, and the issues table's true total. It is
196 // threaded from Build into buildDetail so the detail pane can say its read was
197 // partial — every number in it comes from reads already made.
198 type readClip struct {
199 truncated bool // some table this projection reads exceeded Max
200 issuesTotal int // the issues table's reported total, clipped or not
201 }
202
203 // buildDetail assembles the single-issue view: the issue's own fields, its
204 // dependency edges in both directions (target title/status resolved), its
205 // comments thread, and a merged history timeline. When the issue is an epic
206 // (issue_type == "epic") it switches to Mode "epic" and also gathers the
207 // parent-child children as a subtask rollup.
208 func buildDetail(
209 ctx context.Context, sess BrowseSession, ref, want string,
210 issues *browse.RowPage, issueCols map[string]int,
211 deps *browse.RowPage, depCols map[string]int,
212 labels *browse.RowPage, labelsByIssue map[string][]string,
213 catByStatus, catByIssue map[string]string,
214 clip readClip,
215 23 ) *Data {
216 23 // id → (title, status, whole row) for edge labels and the subtask rollup.
217 23 titleByIssue := map[string]string{}
218 23 statusByIssue := map[string]string{}
219 23 rowByID := make(map[string]rowCells, len(issues.Rows))
220 23 var row rowCells
221 6080 for _, r := range rowsOf(issues) {
222 6080 id := cell(issueCols, r, "id")
223 6080 titleByIssue[id] = cell(issueCols, r, "title")
224 6080 statusByIssue[id] = cell(issueCols, r, "status")
225 6080 rowByID[id] = r
226 6080 if id == want {
227 20 row = r
228 20 }
229 }
230
231 23 data := &Data{Mode: "detail", Truncated: clip.truncated, ShownOf: clip.issuesTotal}
232 23 if row.values == nil {
233 3 // Unknown id: a detail pane with a nil Issue. Whether that is an answer or
234 3 // an admission is Data.MissingBeyondCap's to tell — when the issues table
235 3 // was clipped at Max the id may simply live in the tail that was never
236 3 // read, and "no such issue" is a claim this projection cannot make.
237 3 return data
238 3 }
239
240 // An epic gets its own rendering mode; the template branches on it to add the
241 // subtask rollup while reusing the shared detail chrome.
242 20 if strings.EqualFold(cell(issueCols, row, "issue_type"), "epic") {
243 4 data.Mode = "epic"
244 4 }
245
246 20 status := cell(issueCols, row, "status")
247 20 name, accent := laneForCategory(statusCategory(status, catByStatus))
248 20 data.Issue = &Issue{
249 20 ID: want,
250 20 Title: cell(issueCols, row, "title"),
251 20 Status: status,
252 20 Lane: name,
253 20 Accent: accent,
254 20 Priority: cell(issueCols, row, "priority"),
255 20 IssueType: cell(issueCols, row, "issue_type"),
256 20 Assignee: cell(issueCols, row, "assignee"),
257 20 CreatedBy: cell(issueCols, row, "created_by"),
258 20 Owner: cell(issueCols, row, "owner"),
259 20 EstimatedMinutes: cell(issueCols, row, "estimated_minutes"),
260 20 ExternalRef: cell(issueCols, row, "external_ref"),
261 20 SpecID: cell(issueCols, row, "spec_id"),
262 20 Description: cell(issueCols, row, "description"),
263 20 Design: cell(issueCols, row, "design"),
264 20 AcceptanceCriteria: cell(issueCols, row, "acceptance_criteria"),
265 20 Notes: cell(issueCols, row, "notes"),
266 20 CreatedAt: cell(issueCols, row, "created_at"),
267 20 StartedAt: cell(issueCols, row, "started_at"),
268 20 UpdatedAt: cell(issueCols, row, "updated_at"),
269 20 ClosedAt: cell(issueCols, row, "closed_at"),
270 20 CloseReason: cell(issueCols, row, "close_reason"),
271 20 Labels: labelsByIssue[want],
272 20 }
273 20
274 20 // The stored rows behind everything above, in the order the tables were read.
275 20 // Only the tables that hold rows *of this issue* are here: custom_statuses is
276 20 // read for the lane, but its rows describe the tracker's statuses rather than
277 20 // this issue, so there is no row of it that belongs on this pane. comments and
278 20 // events are added below, where they are read.
279 20 data.addRaw(rawTableOf("issues", issues, matchColumn("id", want)))
280 20 data.addRaw(rawTableOf("labels", labels, matchColumn("issue_id", want)))
281 32 data.addRaw(rawTableOf("dependencies", deps, func(cols map[string]int, r rowCells) bool {
282 32 // Both directions: an edge is this issue's whether it points out of it or
283 32 // into it, and the pane draws both lists from exactly these rows.
284 32 return cell(cols, r, "issue_id") == want || cell(cols, r, "depends_on_issue_id") == want
285 32 }))
286
287 26 edge := func(id, typ string) Edge {
288 26 st := statusByIssue[id]
289 26 return Edge{
290 26 IssueID: id,
291 26 Title: titleByIssue[id],
292 26 Type: typ,
293 26 Status: st,
294 26 Closed: statusCategory(st, catByStatus) == "closed",
295 26 }
296 26 }
297 32 for _, r := range rowsOf(deps) {
298 32 from := cell(depCols, r, "issue_id")
299 32 to := cell(depCols, r, "depends_on_issue_id")
300 32 typ := cell(depCols, r, "type")
301 32 if from == want && to != "" {
302 4 data.DependsOn = append(data.DependsOn, edge(to, typ))
303 4 }
304 32 if to == want && from != "" {
305 22 data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
306 22 // A parent-child edge pointing at this issue makes `from` a subtask,
307 22 // but that only matters when this issue is an epic.
308 22 if data.Mode == "epic" && strings.EqualFold(typ, "parent-child") {
309 12 cr := rowByID[from]
310 12 cat := catByIssue[from]
311 12 st := Subtask{
312 12 ID: from,
313 12 Title: titleByIssue[from],
314 12 Status: statusByIssue[from],
315 12 Category: cat,
316 12 Priority: cell(issueCols, cr, "priority"),
317 12 Assignee: cell(issueCols, cr, "assignee"),
318 12 Blocked: truthy(cell(issueCols, cr, "is_blocked")),
319 12 }
320 12 data.Subtasks = append(data.Subtasks, st)
321 12 data.SubtaskTotal++
322 12 if cat == "closed" {
323 4 data.SubtaskDone++
324 4 }
325 }
326 }
327 // beads logs no event for a dependency/subtask link, but the row records
328 // created_at/created_by — synthesize a timeline entry so "added subtask X"
329 // (and other edge additions) appear in History.
330 32 if act, ok := depActivity(want, from, to, typ,
331 32 cell(depCols, r, "created_at"), cell(depCols, r, "created_by")); ok {
332 12 data.History = append(data.History, act)
333 12 }
334 }
335 20 sortSubtasks(data.Subtasks)
336 20
337 20 // Transitive dependency trees over the full edge set. Kept only when they
338 20 // reach past the direct edges (a Depth>0 node), so they add the chain the
339 20 // flat Depends-on / Depended-on-by lists can't show, without duplicating them.
340 20 outAdj := map[string][]depLink{} // id → things it depends on
341 20 inAdj := map[string][]depLink{} // id → things that depend on it
342 32 for _, r := range rowsOf(deps) {
343 32 from := cell(depCols, r, "issue_id")
344 32 to := cell(depCols, r, "depends_on_issue_id")
345 32 if from == "" || to == "" {
346 0 continue
347 }
348 32 typ := cell(depCols, r, "type")
349 32 outAdj[from] = append(outAdj[from], depLink{to: to, typ: typ})
350 32 inAdj[to] = append(inAdj[to], depLink{to: from, typ: typ})
351 }
352 20 if t := buildDepTree(want, outAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
353 1 data.DependsTree = t
354 1 }
355 20 if t := buildDepTree(want, inAdj, titleByIssue, statusByIssue, catByStatus); hasTransitive(t) {
356 1 data.DependentTree = t
357 1 }
358
359 // Comments are optional; a missing table just yields an empty thread. Each
360 // comment is also folded into the merged history timeline below. A clipped
361 // comments table costs this issue's thread whatever sits past Max, so it
362 // counts towards the flag like any other input.
363 20 if comments, commentsTotal, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
364 11 data.Truncated = data.Truncated || commentsTotal > Max
365 11 data.addRaw(rawTableOf("comments", comments, matchColumn("issue_id", want)))
366 11 ccols := indexCols(comments.Columns)
367 2075 for _, r := range rowsOf(comments) {
368 2075 if cell(ccols, r, "issue_id") != want {
369 11 continue
370 }
371 2064 author := cell(ccols, r, "author")
372 2064 text := cell(ccols, r, "text")
373 2064 at := cell(ccols, r, "created_at")
374 2064 data.Comments = append(data.Comments, Comment{Author: author, Text: text, CreatedAt: at})
375 2064 data.History = append(data.History, Activity{
376 2064 Kind: "comment",
377 2064 Actor: author,
378 2064 Summary: "commented",
379 2064 Text: text,
380 2064 CreatedAt: at,
381 2064 })
382 }
383 }
384
385 // The audit log (events) is optional too; when present it joins the comments
386 // in the History tab as humanized, time-ordered entries.
387 20 if events, eventsTotal, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
388 10 data.Truncated = data.Truncated || eventsTotal > Max
389 10 data.addRaw(rawTableOf("events", events, matchColumn("issue_id", want)))
390 10 ecols := indexCols(events.Columns)
391 26 for _, r := range rowsOf(events) {
392 26 if cell(ecols, r, "issue_id") != want {
393 9 continue
394 }
395 17 et := cell(ecols, r, "event_type")
396 17 summary, text := humanizeEvent(et,
397 17 cell(ecols, r, "old_value"), cell(ecols, r, "new_value"), cell(ecols, r, "comment"))
398 17 data.History = append(data.History, Activity{
399 17 Kind: "event",
400 17 Event: et,
401 17 Actor: cell(ecols, r, "actor"),
402 17 Summary: summary,
403 17 Text: text,
404 17 CreatedAt: cell(ecols, r, "created_at"),
405 17 })
406 }
407 }
408
409 20 sortActivity(data.History)
410 20 return data
411 }
412
413 // collectFilterOptions gathers the distinct issue_type / priority / assignee
414 // values and label names across all issues, sorted, for the filter dropdowns.
415 51 func collectFilterOptions(issues *browse.RowPage, cols map[string]int, labelsByIssue map[string][]string) FilterOptions {
416 51 types, prios, assignees, labels := map[string]bool{}, map[string]bool{}, map[string]bool{}, map[string]bool{}
417 6470 for _, r := range rowsOf(issues) {
418 6470 if t := cell(cols, r, "issue_type"); t != "" {
419 6466 types[t] = true
420 6466 }
421 6470 if p := cell(cols, r, "priority"); p != "" {
422 6466 prios[p] = true
423 6466 }
424 6470 if a := cell(cols, r, "assignee"); a != "" {
425 6466 assignees[a] = true
426 6466 }
427 }
428 51 for _, lbs := range labelsByIssue {
429 6034 for _, l := range lbs {
430 6034 labels[l] = true
431 6034 }
432 }
433 51 return FilterOptions{
434 51 Types: sortedKeys(types),
435 51 Priorities: sortedKeys(prios), // single digits sort numerically as strings
436 51 Assignees: sortedKeys(assignees),
437 51 Labels: sortedKeys(labels),
438 51 }
439 }
440
441 // issueCreatedAt maps issue id → created_at string, for lane sorting.
442 51 func issueCreatedAt(issues *browse.RowPage, cols map[string]int) map[string]string {
443 51 m := make(map[string]string, len(issues.Rows))
444 6470 for _, r := range rowsOf(issues) {
445 6470 m[cell(cols, r, "id")] = cell(cols, r, "created_at")
446 6470 }
447 51 return m
448 }
449
450 // sortCards orders a lane by priority (0 = highest first), then created_at
451 // ascending, then id — a stable, deterministic parade order.
452 204 func sortCards(cards []Card, created map[string]string) {
453 38851 sort.SliceStable(cards, func(i, j int) bool {
454 38851 pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority)
455 38851 if pi != pj {
456 227 return pi < pj
457 227 }
458 38624 ci, cj := created[cards[i].ID], created[cards[j].ID]
459 38624 if ci != cj {
460 25643 return ci < cj
461 25643 }
462 12981 return cards[i].ID < cards[j].ID
463 })
464 }
465
466 // priorityRank parses a priority to an int for sorting; unset/unparseable sorts
467 // last (a large rank).
468 82742 func priorityRank(p string) int {
469 82742 if p == "" {
470 2 return 1 << 30
471 2 }
472 82740 n, err := strconv.Atoi(strings.TrimSpace(p))
473 82740 if err != nil {
474 0 return 1 << 30
475 0 }
476 82740 return n
477 }
478
479 // sortSubtasks orders an epic's children open-work-first: unclosed before
480 // closed, then by priority (0 highest), then id — closed subtasks sink to the
481 // bottom so the actionable ones lead.
482 20 func sortSubtasks(subs []Subtask) {
483 20 sort.SliceStable(subs, func(i, j int) bool {
484 12 ci, cj := subs[i].Category == "closed", subs[j].Category == "closed"
485 12 if ci != cj {
486 8 return !ci // open (false) sorts before closed (true)
487 8 }
488 4 pi, pj := priorityRank(subs[i].Priority), priorityRank(subs[j].Priority)
489 4 if pi != pj {
490 4 return pi < pj
491 4 }
492 0 return subs[i].ID < subs[j].ID
493 })
494 }
495
496 // sortActivity orders the merged history oldest-first (chronological). Timestamps
497 // share the "YYYY-MM-DD HH:MM:SS" shape across events and comments, so a lexical
498 // compare is a time compare; ties fall back to id-free but stable order.
499 20 func sortActivity(acts []Activity) {
500 2562 sort.SliceStable(acts, func(i, j int) bool {
501 2562 return acts[i].CreatedAt < acts[j].CreatedAt
502 2562 })
503 }