| 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 |
|
} |