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. 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 Issues []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") 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{} 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++ } md.Issues = append(md.Issues, 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] sortMilestoneCards(md.Issues) out = append(out, *md) } return &MilestoneView{Milestones: out, Unlabeled: unlabeled, Total: len(issues.Rows)}, nil } // 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 { ci, cj := cards[i].Category == "closed", cards[j].Category == "closed" if ci != cj { return !ci } pi, pj := priorityRank(cards[i].Priority), priorityRank(cards[j].Priority) if pi != pj { return pi < pj } return cards[i].ID < cards[j].ID }) }