~bigbes/sr-ht-dolt

8527f0fdd75341f817d55c259583dd1dbebe173f — Eugene Blikh 13 days ago 5836cb6
feat(web/beads): hierarchy in the milestone view

Arrange each milestone as a shallow hierarchy instead of a flat list.
- Put the milestone-typed issue(s) on top as the milestone's heads.
- Nest tasks under their epics via parent-child dependency edges,
  with a done/total rollup on the epic row; membership stays label-based.
- Keep remaining members as a loose tail; blocks edges are ignored.
- Read the dependencies table optionally, degrading to a flat list.
- Extend fixtures and real-data logging to cover the hierarchy.
4 files changed, 188 insertions(+), 44 deletions(-)

M web/milestones.go
M web/milestones_test.go
M web/realdata_test.go
M web/templates/milestones.html
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{
		"&middot; 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
		"&middot; 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 &middot; {{.Open}} open{{if .InProgress}} &middot; {{.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}}