package web import ( "context" "net/url" "sort" "strings" "sourcecraft.dev/bigbes/sr-ht-dolt/browse" "sourcecraft.dev/bigbes/sr-ht-dolt/core" ) // milestonePrefix marks labels that name a milestone; the view groups by them. const milestonePrefix = "milestone:" // milestonesView groups a beads issue DB by its "milestone:" labels and // shows per-milestone progress with the issues under each. It shares the beads // fingerprint, so it appears as a companion tab wherever the Beads view does. type milestonesView struct{} func init() { RegisterView(&milestonesView{}) } func (*milestonesView) Name() string { return "milestones" } func (*milestonesView) Label() string { return "Milestones" } func (*milestonesView) Template() string { return "milestones.html" } // Applies mirrors the beads fingerprint so Milestones and Beads pair up. A // beads DB that happens to use no milestone labels still gets the tab; it just // renders an empty state. func (*milestonesView) Applies(tables []browse.TableInfo) bool { return (&beadsView{}).Applies(tables) } // 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 []BeadCard // issue_type == "milestone" — the milestone's own issue(s) Epics []MilestoneEpic // epics in the milestone, each with its nested subtasks Loose []BeadCard // 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 BeadCard Done int // closed children, for the "d/t" rollup on the epic row Total int Children []BeadCard } // 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 } // Build 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 (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo, ref string, _ url.Values) (any, 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 := map[string]string{} if statuses != nil { cols := indexCols(statuses.Columns) for _, r := range statuses.Rows { if name := cell(cols, r, "name"); name != "" { catByStatus[strings.ToLower(name)] = strings.ToLower(cell(cols, r, "category")) } } } labelsByIssue := map[string][]string{} if labels != nil { cols := indexCols(labels.Columns) for _, r := range labels.Rows { id := cell(cols, r, "issue_id") lb := cell(cols, r, "label") if id != "" && lb != "" { labelsByIssue[id] = append(labelsByIssue[id], lb) } } } issueCols := indexCols(issues.Columns) byLabel := map[string]*MilestoneDetail{} cardsByLabel := map[string][]BeadCard{} unlabeled := 0 for _, r := range issues.Rows { id := cell(issueCols, r, "id") cat := statusCategory(cell(issueCols, r, "status"), catByStatus) card := BeadCard{ 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 []BeadCard, 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 []BeadCard) { sort.SliceStable(cards, func(i, j int) bool { return milestoneCardLess(cards[i], cards[j]) }) } func milestoneCardLess(a, b BeadCard) 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 }