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

Coverage
87.6% 85/97 statements
Δ
Blob
024e1db
1 package beads
2
3 import (
4 "context"
5 "sort"
6 "strings"
7 )
8
9 // milestonePrefix marks labels that name a milestone; the rollup groups by them.
10 const milestonePrefix = "milestone:"
11
12 // MilestoneView is the opaque .Data handed to milestones.html.
13 type MilestoneView struct {
14 Milestones []MilestoneDetail
15 Unlabeled int // issues carrying no milestone label
16 Total int // all issues read
17
18 // Truncated says one of the tables this rollup is computed from — issues,
19 // labels, dependencies, custom_statuses — exceeded Max and came back clipped,
20 // so every count below is arithmetic over a partial read. A clipped labels
21 // table is the quietest of them: membership itself goes missing, and a
22 // milestone can lose issues rather than merely undercount them.
23 Truncated bool
24 // ShownOf is the issues table's reported total, clipped or not. Total is what
25 // was read, ShownOf is what exists; they differ exactly when the issues read
26 // was clipped.
27 ShownOf int
28 }
29
30 // IssuesClipped reports that the issues table itself exceeded Max, so this
31 // rollup covers only its first Max rows.
32 3 func (v *MilestoneView) IssuesClipped() bool { return v.ShownOf > Max }
33
34 // MilestoneDetail is one milestone's rollup and the issues under it, arranged
35 // as a shallow hierarchy: the milestone-typed issue(s) first, then epics with
36 // their subtasks nested one level below, then everything else.
37 type MilestoneDetail struct {
38 Name string // label with the "milestone:" prefix stripped
39 Label string // full label, for filter links back to the board
40 Total int
41 Done int // closed
42 InProgress int
43 Open int // open (or unknown) — the remaining work
44 Heads []Card // issue_type == "milestone" — the milestone's own issue(s)
45 Epics []MilestoneEpic // epics in the milestone, each with its nested subtasks
46 Loose []Card // members that are neither heads, epics, nor nested subtasks
47 }
48
49 // MilestoneEpic is an epic inside a milestone together with the milestone
50 // members nested under it (parent-child edges pointing at the epic).
51 type MilestoneEpic struct {
52 Card Card
53 Done int // closed children, for the "d/t" rollup on the epic row
54 Total int
55 Children []Card
56 }
57
58 // Pct is the milestone's completion percentage (0..100) for the progress bar.
59 1 func (m MilestoneDetail) Pct() int {
60 1 if m.Total == 0 {
61 0 return 0
62 0 }
63 1 return m.Done * 100 / m.Total
64 }
65
66 // BuildMilestones reads the issues and their labels, then groups by milestone
67 // label. An issue with several milestone labels counts under each. Missing
68 // labels/statuses tables degrade to empty (no milestones), never an error.
69 5 func BuildMilestones(ctx context.Context, sess BrowseSession, ref string) (*MilestoneView, error) {
70 5 issues, issuesTotal, err := readRows(ctx, sess, ref, "issues")
71 5 if err != nil {
72 0 return nil, err
73 0 }
74 5 labels, labelsTotal, _ := readRowsOptional(ctx, sess, ref, "labels")
75 5 statuses, statusesTotal, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
76 5 deps, depsTotal, _ := readRowsOptional(ctx, sess, ref, "dependencies")
77 5
78 5 // Every one of those four feeds the arithmetic below, so any one of them
79 5 // coming back clipped makes the rollup partial. The totals come back from the
80 5 // reads just made; nothing here reads a table twice to find out.
81 5 truncated := issuesTotal > Max || labelsTotal > Max ||
82 5 statusesTotal > Max || depsTotal > Max
83 5
84 5 // child issue → its parent-child parents; used to nest tasks under epics.
85 5 parentsByChild := map[string][]string{}
86 5 if deps != nil {
87 5 cols := indexCols(deps.Columns)
88 7 for _, r := range rowsOf(deps) {
89 7 if !strings.EqualFold(cell(cols, r, "type"), "parent-child") {
90 5 continue
91 }
92 2 child := cell(cols, r, "issue_id")
93 2 parent := cell(cols, r, "depends_on_issue_id")
94 2 if child != "" && parent != "" {
95 2 parentsByChild[child] = append(parentsByChild[child], parent)
96 2 }
97 }
98 }
99
100 5 catByStatus := indexStatusCategories(statuses)
101 5 labelsByIssue := indexLabels(labels)
102 5
103 5 issueCols := indexCols(issues.Columns)
104 5 byLabel := map[string]*MilestoneDetail{}
105 5 cardsByLabel := map[string][]Card{}
106 5 unlabeled := 0
107 2019 for _, r := range rowsOf(issues) {
108 2019 id := cell(issueCols, r, "id")
109 2019 cat := statusCategory(cell(issueCols, r, "status"), catByStatus)
110 2019 card := Card{
111 2019 ID: id,
112 2019 Title: cell(issueCols, r, "title"),
113 2019 Type: cell(issueCols, r, "issue_type"),
114 2019 Priority: cell(issueCols, r, "priority"),
115 2019 Assignee: cell(issueCols, r, "assignee"),
116 2019 Category: cat,
117 2019 }
118 2019 seen := false
119 2019 for _, l := range labelsByIssue[id] {
120 2016 if !strings.HasPrefix(l, milestonePrefix) {
121 2004 continue
122 }
123 12 seen = true
124 12 md := byLabel[l]
125 12 if md == nil {
126 6 md = &MilestoneDetail{Name: strings.TrimPrefix(l, milestonePrefix), Label: l}
127 6 byLabel[l] = md
128 6 }
129 12 md.Total++
130 12 switch cat {
131 2 case "closed":
132 2 md.Done++
133 0 case "in_progress":
134 0 md.InProgress++
135 10 default:
136 10 md.Open++
137 }
138 12 cardsByLabel[l] = append(cardsByLabel[l], card)
139 }
140 2019 if !seen {
141 2007 unlabeled++
142 2007 }
143 }
144
145 5 names := make([]string, 0, len(byLabel))
146 6 for l := range byLabel {
147 6 names = append(names, l)
148 6 }
149 5 sort.Strings(names)
150 5 out := make([]MilestoneDetail, 0, len(names))
151 6 for _, l := range names {
152 6 md := byLabel[l]
153 6 md.arrange(cardsByLabel[l], parentsByChild)
154 6 out = append(out, *md)
155 6 }
156
157 5 return &MilestoneView{
158 5 Milestones: out,
159 5 Unlabeled: unlabeled,
160 5 Total: len(issues.Rows),
161 5 Truncated: truncated,
162 5 ShownOf: issuesTotal,
163 5 }, nil
164 }
165
166 // arrange splits a milestone's member cards into the display hierarchy: heads
167 // (issue_type "milestone") on top, then epics with the members parent-child'ed
168 // under them, then the leftovers. Only members of this milestone participate —
169 // membership stays purely label-based; a child nests only when its epic carries
170 // the same milestone label.
171 6 func (md *MilestoneDetail) arrange(cards []Card, parentsByChild map[string][]string) {
172 6 epicByID := map[string]*MilestoneEpic{}
173 6 var epics []*MilestoneEpic
174 12 for _, c := range cards {
175 12 switch {
176 2 case strings.EqualFold(c.Type, "milestone"):
177 2 md.Heads = append(md.Heads, c)
178 2 case strings.EqualFold(c.Type, "epic"):
179 2 e := &MilestoneEpic{Card: c}
180 2 epicByID[c.ID] = e
181 2 epics = append(epics, e)
182 }
183 }
184 12 for _, c := range cards {
185 12 if strings.EqualFold(c.Type, "milestone") || strings.EqualFold(c.Type, "epic") {
186 4 continue
187 }
188 8 var home *MilestoneEpic
189 8 for _, p := range parentsByChild[c.ID] {
190 2 if e := epicByID[p]; e != nil {
191 2 home = e
192 2 break
193 }
194 }
195 8 if home == nil {
196 6 md.Loose = append(md.Loose, c)
197 6 continue
198 }
199 2 home.Total++
200 2 if c.Category == "closed" {
201 2 home.Done++
202 2 }
203 2 home.Children = append(home.Children, c)
204 }
205
206 6 sortMilestoneCards(md.Heads)
207 6 sortMilestoneCards(md.Loose)
208 6 sort.SliceStable(epics, func(i, j int) bool {
209 0 return milestoneCardLess(epics[i].Card, epics[j].Card)
210 0 })
211 6 for _, e := range epics {
212 2 sortMilestoneCards(e.Children)
213 2 md.Epics = append(md.Epics, *e)
214 2 }
215 }
216
217 // sortMilestoneCards orders a milestone's issues open-work-first (closed sinks to
218 // the bottom), then by priority, then id — a stable, deterministic order.
219 14 func sortMilestoneCards(cards []Card) {
220 14 sort.SliceStable(cards, func(i, j int) bool {
221 0 return milestoneCardLess(cards[i], cards[j])
222 0 })
223 }
224
225 0 func milestoneCardLess(a, b Card) bool {
226 0 ca, cb := a.Category == "closed", b.Category == "closed"
227 0 if ca != cb {
228 0 return !ca
229 0 }
230 0 pa, pb := priorityRank(a.Priority), priorityRank(b.Priority)
231 0 if pa != pb {
232 0 return pa < pb
233 0 }
234 0 return a.ID < b.ID
235 }