package beads
import (
"context"
"sort"
"strings"
)
// milestonePrefix marks labels that name a milestone; the rollup groups by them.
const milestonePrefix = "milestone:"
// MilestoneView is the opaque .Data handed to milestones.html.
type MilestoneView struct {
Milestones []MilestoneDetail
Unlabeled int // issues carrying no milestone label
Total int // all issues read
}
// MilestoneDetail is one milestone's rollup and the issues under it, arranged
// as a shallow hierarchy: the milestone-typed issue(s) first, then epics with
// their subtasks nested one level below, then everything else.
type MilestoneDetail struct {
Name string // label with the "milestone:" prefix stripped
Label string // full label, for filter links back to the board
Total int
Done int // closed
InProgress int
Open int // open (or unknown) — the remaining work
Heads []Card // issue_type == "milestone" — the milestone's own issue(s)
Epics []MilestoneEpic // epics in the milestone, each with its nested subtasks
Loose []Card // members that are neither heads, epics, nor nested subtasks
}
// MilestoneEpic is an epic inside a milestone together with the milestone
// members nested under it (parent-child edges pointing at the epic).
type MilestoneEpic struct {
Card Card
Done int // closed children, for the "d/t" rollup on the epic row
Total int
Children []Card
}
// Pct is the milestone's completion percentage (0..100) for the progress bar.
func (m MilestoneDetail) Pct() int {
if m.Total == 0 {
return 0
}
return m.Done * 100 / m.Total
}
// BuildMilestones reads the issues and their labels, then groups by milestone
// label. An issue with several milestone labels counts under each. Missing
// labels/statuses tables degrade to empty (no milestones), never an error.
func BuildMilestones(ctx context.Context, sess BrowseSession, ref string) (*MilestoneView, error) {
issues, _, err := readRows(ctx, sess, ref, "issues")
if err != nil {
return nil, err
}
labels, _, _ := readRowsOptional(ctx, sess, ref, "labels")
statuses, _, _ := readRowsOptional(ctx, sess, ref, "custom_statuses")
deps, _, _ := readRowsOptional(ctx, sess, ref, "dependencies")
// child issue → its parent-child parents; used to nest tasks under epics.
parentsByChild := map[string][]string{}
if deps != nil {
cols := indexCols(deps.Columns)
for _, r := range deps.Rows {
if !strings.EqualFold(cell(cols, r, "type"), "parent-child") {
continue
}
child := cell(cols, r, "issue_id")
parent := cell(cols, r, "depends_on_issue_id")
if child != "" && parent != "" {
parentsByChild[child] = append(parentsByChild[child], parent)
}
}
}
catByStatus := indexStatusCategories(statuses)
labelsByIssue := indexLabels(labels)
issueCols := indexCols(issues.Columns)
byLabel := map[string]*MilestoneDetail{}
cardsByLabel := map[string][]Card{}
unlabeled := 0
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
cat := statusCategory(cell(issueCols, r, "status"), catByStatus)
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"),
Category: cat,
}
seen := false
for _, l := range labelsByIssue[id] {
if !strings.HasPrefix(l, milestonePrefix) {
continue
}
seen = true
md := byLabel[l]
if md == nil {
md = &MilestoneDetail{Name: strings.TrimPrefix(l, milestonePrefix), Label: l}
byLabel[l] = md
}
md.Total++
switch cat {
case "closed":
md.Done++
case "in_progress":
md.InProgress++
default:
md.Open++
}
cardsByLabel[l] = append(cardsByLabel[l], card)
}
if !seen {
unlabeled++
}
}
names := make([]string, 0, len(byLabel))
for l := range byLabel {
names = append(names, l)
}
sort.Strings(names)
out := make([]MilestoneDetail, 0, len(names))
for _, l := range names {
md := byLabel[l]
md.arrange(cardsByLabel[l], parentsByChild)
out = append(out, *md)
}
return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil
}
// arrange splits a milestone's member cards into the display hierarchy: heads
// (issue_type "milestone") on top, then epics with the members parent-child'ed
// under them, then the leftovers. Only members of this milestone participate —
// membership stays purely label-based; a child nests only when its epic carries
// the same milestone label.
func (md *MilestoneDetail) arrange(cards []Card, parentsByChild map[string][]string) {
epicByID := map[string]*MilestoneEpic{}
var epics []*MilestoneEpic
for _, c := range cards {
switch {
case strings.EqualFold(c.Type, "milestone"):
md.Heads = append(md.Heads, c)
case strings.EqualFold(c.Type, "epic"):
e := &MilestoneEpic{Card: c}
epicByID[c.ID] = e
epics = append(epics, e)
}
}
for _, c := range cards {
if strings.EqualFold(c.Type, "milestone") || strings.EqualFold(c.Type, "epic") {
continue
}
var home *MilestoneEpic
for _, p := range parentsByChild[c.ID] {
if e := epicByID[p]; e != nil {
home = e
break
}
}
if home == nil {
md.Loose = append(md.Loose, c)
continue
}
home.Total++
if c.Category == "closed" {
home.Done++
}
home.Children = append(home.Children, c)
}
sortMilestoneCards(md.Heads)
sortMilestoneCards(md.Loose)
sort.SliceStable(epics, func(i, j int) bool {
return milestoneCardLess(epics[i].Card, epics[j].Card)
})
for _, e := range epics {
sortMilestoneCards(e.Children)
md.Epics = append(md.Epics, *e)
}
}
// sortMilestoneCards orders a milestone's issues open-work-first (closed sinks to
// the bottom), then by priority, then id — a stable, deterministic order.
func sortMilestoneCards(cards []Card) {
sort.SliceStable(cards, func(i, j int) bool {
return milestoneCardLess(cards[i], cards[j])
})
}
func milestoneCardLess(a, b Card) bool {
ca, cb := a.Category == "closed", b.Category == "closed"
if ca != cb {
return !ca
}
pa, pb := priorityRank(a.Priority), priorityRank(b.Priority)
if pa != pb {
return pa < pb
}
return a.ID < b.ID
}