M web/milestones.go => web/milestones.go +100 -14
@@ 38,15 38,28 @@ type MilestoneView struct {
Total int // all issues read
}
-// MilestoneDetail is one milestone's rollup and the issues under it.
+// 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
- Issues []BeadCard
+ 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.
@@ 67,6 80,23 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.
}
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 {
@@ 92,6 122,7 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.
issueCols := indexCols(issues.Columns)
byLabel := map[string]*MilestoneDetail{}
+ cardsByLabel := map[string][]BeadCard{}
unlabeled := 0
for _, r := range issues.Rows {
id := cell(issueCols, r, "id")
@@ 124,7 155,7 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.
default:
md.Open++
}
- md.Issues = append(md.Issues, card)
+ cardsByLabel[l] = append(cardsByLabel[l], card)
}
if !seen {
unlabeled++
@@ 139,25 170,80 @@ func (v *milestonesView) Build(ctx context.Context, sess BrowseSession, _ *core.
out := make([]MilestoneDetail, 0, len(names))
for _, l := range names {
md := byLabel[l]
- sortMilestoneCards(md.Issues)
+ 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 {
- 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
+ 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
+}
M web/milestones_test.go => web/milestones_test.go +53 -21
@@ 11,28 11,41 @@ import (
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
-// milestoneFixture: four issues across two milestones (m1 has one closed), one
-// issue with no milestone label.
+// milestoneFixture: six issues across two milestones. m1 carries the full
+// hierarchy — a milestone-typed head, an epic with one (closed) subtask, and a
+// loose bug; m2 has a single loose feature; one issue has no milestone label.
func milestoneFixture() *fakeSession {
issues := &browse.RowPage{
Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee"},
Rows: [][]string{
- {"i-a", "Alpha", "closed", "1", "task", "alice"},
+ {"i-m", "Mike", "open", "0", "milestone", ""},
+ {"i-e", "Echo", "open", "1", "epic", ""},
+ {"i-a", "Alpha", "closed", "1", "task", "alice"}, // subtask of i-e
{"i-b", "Bravo", "open", "0", "bug", "bob"},
{"i-c", "Charlie", "open", "2", "feature", ""},
{"i-d", "Delta", "open", "1", "task", ""}, // no milestone
},
- Total: 4,
+ Total: 6,
}
labels := &browse.RowPage{
Columns: []string{"issue_id", "label"},
Rows: [][]string{
+ {"i-m", "milestone:m1"},
+ {"i-e", "milestone:m1"},
{"i-a", "milestone:m1"},
{"i-b", "milestone:m1"},
{"i-c", "milestone:m2"},
{"i-b", "backend"}, // non-milestone label ignored by grouping
},
- Total: 4,
+ Total: 6,
+ }
+ deps := &browse.RowPage{
+ Columns: []string{"issue_id", "depends_on_issue_id", "type"},
+ Rows: [][]string{
+ {"i-a", "i-e", "parent-child"},
+ {"i-b", "i-a", "blocks"}, // non-hierarchy edge ignored by nesting
+ },
+ Total: 2,
}
statuses := &browse.RowPage{
Columns: []string{"name", "category"},
@@ 45,6 58,7 @@ func milestoneFixture() *fakeSession {
rowsByTable: map[string]*browse.RowPage{
"issues": issues,
"labels": labels,
+ "dependencies": deps,
"custom_statuses": statuses,
},
}
@@ 70,24 84,38 @@ func TestMilestonesBuild(t *testing.T) {
if len(d.Milestones) != 2 {
t.Fatalf("milestones = %d, want 2: %+v", len(d.Milestones), d.Milestones)
}
- if d.Unlabeled != 1 || d.Total != 4 {
- t.Errorf("unlabeled=%d total=%d, want 1 and 4", d.Unlabeled, d.Total)
+ if d.Unlabeled != 1 || d.Total != 6 {
+ t.Errorf("unlabeled=%d total=%d, want 1 and 6", d.Unlabeled, d.Total)
}
m1 := d.Milestones[0]
- if m1.Name != "m1" || m1.Total != 2 || m1.Done != 1 || m1.Open != 1 || m1.Pct() != 50 {
- t.Errorf("m1 = %+v, want name m1 total 2 done 1 open 1 pct 50", m1)
+ if m1.Name != "m1" || m1.Total != 4 || m1.Done != 1 || m1.Open != 3 || m1.Pct() != 25 {
+ t.Errorf("m1 = %+v, want name m1 total 4 done 1 open 3 pct 25", m1)
+ }
+ // Hierarchy: the milestone-typed issue heads the list, the epic nests its
+ // subtask, and the bug (whose only dep edge is "blocks") stays loose.
+ if len(m1.Heads) != 1 || m1.Heads[0].ID != "i-m" {
+ t.Errorf("m1 heads = %+v, want [i-m]", m1.Heads)
}
- // Issues under m1, open-first: i-b (open) before i-a (closed).
- if len(m1.Issues) != 2 || m1.Issues[0].ID != "i-b" || m1.Issues[1].ID != "i-a" {
- t.Errorf("m1 issue order = %+v, want [i-b i-a]", m1.Issues)
+ if len(m1.Epics) != 1 || m1.Epics[0].Card.ID != "i-e" {
+ t.Fatalf("m1 epics = %+v, want [i-e]", m1.Epics)
}
- if m1.Issues[1].Category != "closed" {
- t.Errorf("i-a should be closed: %+v", m1.Issues[1])
+ epic := m1.Epics[0]
+ if len(epic.Children) != 1 || epic.Children[0].ID != "i-a" || epic.Done != 1 || epic.Total != 1 {
+ t.Errorf("epic = %+v, want child i-a and rollup 1/1", epic)
+ }
+ if epic.Children[0].Category != "closed" {
+ t.Errorf("i-a should be closed: %+v", epic.Children[0])
+ }
+ if len(m1.Loose) != 1 || m1.Loose[0].ID != "i-b" {
+ t.Errorf("m1 loose = %+v, want [i-b]", m1.Loose)
}
m2 := d.Milestones[1]
if m2.Name != "m2" || m2.Total != 1 || m2.Done != 0 {
t.Errorf("m2 = %+v", m2)
}
+ if len(m2.Heads) != 0 || len(m2.Epics) != 0 || len(m2.Loose) != 1 || m2.Loose[0].ID != "i-c" {
+ t.Errorf("m2 hierarchy = %+v, want only loose [i-c]", m2)
+ }
}
func TestMilestonesRender(t *testing.T) {
@@ 102,13 130,17 @@ func TestMilestonesRender(t *testing.T) {
}
body := rec.Body.String()
for _, want := range []string{
- "· milestones", // page heading
- "ms-title\">m1<", // milestone name
- "1</b>/2 done", // rollup counts
- "Charlie", // an issue under m2
- "carry no milestone", // unlabeled footnote
- `view/milestones">Milestones`, // the tab link
- "nav-link active", // the active tab marker
+ "· milestones", // page heading
+ "ms-title\">m1<", // milestone name
+ "1</b>/4 done", // rollup counts
+ "ms-issue head", // the milestone-typed issue leads the list
+ "ms-issue epic", // the epic row
+ "ms-issue child done", // its subtask, nested and closed
+ `class="r-sub">1/1<`, // the epic's subtask rollup
+ "Charlie", // an issue under m2
+ "carry no milestone", // unlabeled footnote
+ `view/milestones">Milestones`, // the tab link
+ "nav-link active", // the active tab marker
} {
if !strings.Contains(body, want) {
t.Errorf("milestones render missing %q", want)
M web/realdata_test.go => web/realdata_test.go +5 -1
@@ 88,7 88,11 @@ func TestRealBeadsStore(t *testing.T) {
ms := msRaw.(*MilestoneView)
t.Logf("milestones (%d, %d unlabeled of %d):", len(ms.Milestones), ms.Unlabeled, ms.Total)
for _, m := range ms.Milestones {
- t.Logf(" %-24s %d/%d done (%d%%), %d issues", m.Name, m.Done, m.Total, m.Pct(), len(m.Issues))
+ t.Logf(" %-24s %d/%d done (%d%%), %d heads, %d epics, %d loose",
+ m.Name, m.Done, m.Total, m.Pct(), len(m.Heads), len(m.Epics), len(m.Loose))
+ for _, e := range m.Epics {
+ t.Logf(" epic %s %d/%d: %s", e.Card.ID, e.Done, e.Total, e.Card.Title)
+ }
}
// Drive the detail view. With BEADS_REAL_ISSUE set to an epic id, dump the
M web/templates/milestones.html => web/templates/milestones.html +30 -8
@@ 29,6 29,11 @@
.milestones .ms-issue .r-title { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.milestones .ms-issue.done .r-title { text-decoration: line-through; color: var(--bd-muted); }
.milestones .ms-issue .r-meta { flex: 0 0 auto; display: flex; gap: .3rem; }
+.milestones .ms-issue.head { border-left: 3px solid var(--accent); background: var(--bd-panel); }
+.milestones .ms-issue.head .r-title { font-weight: 700; }
+.milestones .ms-issue.epic .r-title { font-weight: 600; }
+.milestones .ms-issue.epic .r-sub { flex: 0 0 auto; font-size: .78rem; color: var(--bd-muted); }
+.milestones .ms-issue.child { padding-left: 2.4rem; }
.milestones .blabel {
display: inline-block; font-size: .72rem; line-height: 1.4; padding: 0 .35rem;
@@ 54,18 59,22 @@
<span class="ms-bar" title="{{.Pct}}% complete"><span class="ms-fill" style="width: {{.Pct}}%"></span></span>
<span class="ms-counts"><b>{{.Done}}</b>/{{.Total}} done · {{.Open}} open{{if .InProgress}} · {{.InProgress}} in progress{{end}}</span>
</div>
- {{range .Issues}}
- <a class="ms-issue {{if eq .Category "closed"}}done{{end}}" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}">
- <span class="r-id">{{.ID}}</span>
- <span class="r-title">{{.Title}}</span>
+ {{range .Heads}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "head")}}{{end}}
+ {{range .Epics}}
+ <a class="ms-issue epic{{if eq .Card.Category "closed"}} done{{end}}" href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.Card.ID | urlquery}}">
+ <span class="r-id">{{.Card.ID}}</span>
+ <span class="r-title">{{.Card.Title}}</span>
+ {{if .Total}}<span class="r-sub">{{.Done}}/{{.Total}}</span>{{end}}
<span class="r-meta">
- {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
- {{if .Type}}<span class="blabel">{{.Type}}</span>{{end}}
- {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
- <span class="blabel muted">{{.Category}}</span>
+ {{if .Card.PriorityLabel}}<span class="blabel prio">{{.Card.PriorityLabel}}</span>{{end}}
+ <span class="blabel">epic</span>
+ {{if .Card.Assignee}}<span class="blabel assignee">@{{.Card.Assignee}}</span>{{end}}
+ <span class="blabel muted">{{.Card.Category}}</span>
</span>
</a>
+ {{range .Children}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "child")}}{{end}}
{{end}}
+ {{range .Loose}}{{template "ms-row" (dict "Repo" $.Repo "Card" . "Class" "")}}{{end}}
</div>
{{end}}
{{if .Data.Unlabeled}}<p class="ms-foot">{{.Data.Unlabeled}} of {{.Data.Total}} issues carry no milestone label.</p>{{end}}
@@ 74,3 83,16 @@
{{end}}
</div>
{{- end}}
+
+{{define "ms-row" -}}
+<a class="ms-issue{{with .Class}} {{.}}{{end}}{{if eq .Card.Category "closed"}} done{{end}}" href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads?issue={{.Card.ID | urlquery}}">
+ <span class="r-id">{{.Card.ID}}</span>
+ <span class="r-title">{{.Card.Title}}</span>
+ <span class="r-meta">
+ {{if .Card.PriorityLabel}}<span class="blabel prio">{{.Card.PriorityLabel}}</span>{{end}}
+ {{if .Card.Type}}<span class="blabel">{{.Card.Type}}</span>{{end}}
+ {{if .Card.Assignee}}<span class="blabel assignee">@{{.Card.Assignee}}</span>{{end}}
+ <span class="blabel muted">{{.Card.Category}}</span>
+ </span>
+</a>
+{{- end}}