Skip to content
228 changes: 194 additions & 34 deletions internal/commands/roadmap.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,29 +31,38 @@ var (

// roadmapData holds the structured roadmap for JSON output.
type roadmapData struct {
Milestones []milestoneGroup `json:"milestones"`
Milestones []milestoneGroup `json:"milestones"`
Unscheduled *unscheduledGroup `json:"unscheduled,omitempty"`
}

// unscheduledGroup represents items not assigned to any milestone.
type unscheduledGroup struct {
Epics []epicGroup `json:"epics,omitempty"`
Other []*bean.Bean `json:"other,omitempty"`
Epics []epicGroup `json:"epics,omitempty"`
Features []featureGroup `json:"features,omitempty"`
Other []*bean.Bean `json:"other,omitempty"`
}

// milestoneGroup represents a milestone and its contents.
type milestoneGroup struct {
Milestone *bean.Bean `json:"milestone"`
Epics []epicGroup `json:"epics,omitempty"`
Other []*bean.Bean `json:"other,omitempty"`
Milestone *bean.Bean `json:"milestone"`
Epics []epicGroup `json:"epics,omitempty"`
Features []featureGroup `json:"features,omitempty"`
Other []*bean.Bean `json:"other,omitempty"`
}

// epicGroup represents an epic and its child items.
type epicGroup struct {
Epic *bean.Bean `json:"epic"`
Items []*bean.Bean `json:"items,omitempty"`
Epic *bean.Bean `json:"epic"`
Items []*bean.Bean `json:"items,omitempty"`
Features []featureGroup `json:"features,omitempty"`
}

// featureGroup represents a feature and the leaf items found anywhere
// beneath it (leafs below nested features are flattened into this list).
type featureGroup struct {
Feature *bean.Bean `json:"feature"`
Items []*bean.Bean `json:"items,omitempty"`
}

var roadmapCmd = &cobra.Command{
Use: "roadmap",
Expand Down Expand Up @@ -130,7 +139,7 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu
for _, m := range milestones {
group := buildMilestoneGroup(m, children, includeDone)
// Only include milestones that have visible content
if len(group.Epics) > 0 || len(group.Other) > 0 {
if len(group.Epics) > 0 || len(group.Features) > 0 || len(group.Other) > 0 {
milestoneGroups = append(milestoneGroups, group)
}
}
Expand Down Expand Up @@ -160,11 +169,9 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu
if underMilestone[b.ID] {
continue
}
// Build epic group if it has visible children
epicItems := filterChildren(children[b.ID], includeDone)
if len(epicItems) > 0 {
sortByTypeThenStatus(epicItems, cfg)
unscheduledEpics = append(unscheduledEpics, epicGroup{Epic: b, Items: epicItems})
eg := buildEpicGroup(b, children, includeDone)
if len(eg.Items) > 0 || len(eg.Features) > 0 {
unscheduledEpics = append(unscheduledEpics, eg)
}
}

Expand All @@ -173,13 +180,53 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu
return unscheduledEpics[i].Epic.Title < unscheduledEpics[j].Epic.Title
})

// Find unscheduled features: feature-typed beans that are not under a
// milestone or epic (orphan features, e.g. created without --parent).
var unscheduledFeatures []featureGroup
for _, b := range allBeans {
if b.Type != "feature" {
continue
}
if underMilestone[b.ID] {
continue
}
// Skip features that are themselves children of an unscheduled epic
// or of another feature above -- those are already rendered as part
// of that ancestor's Features list / flattened into its Items via
// collectLeafDescendants. (feature-under-feature is rejected by
// ValidateParent via the CLI, but beans are hand-editable markdown --
// this guard keeps hand-edited data from double-rendering.)
if b.Parent != "" {
if parent, ok := byID[b.Parent]; ok && (parent.Type == "epic" || parent.Type == "feature") {
continue
}
}
fg := buildFeatureGroup(b, children, includeDone)
if len(fg.Items) > 0 {
unscheduledFeatures = append(unscheduledFeatures, fg)
}
}
sort.Slice(unscheduledFeatures, func(i, j int) bool {
return unscheduledFeatures[i].Feature.Title < unscheduledFeatures[j].Feature.Title
})

// Find orphan items (not milestone, not epic, no parent or parent is not milestone/epic)
var orphanItems []*bean.Bean
for _, b := range allBeans {
// Skip milestones and epics
// Skip milestones and epics -- always containers, never flat leaves.
if b.Type == "milestone" || b.Type == "epic" {
continue
}
if b.Type == "feature" {
// Features with >=1 leaf descendant are rendered via the
// unscheduledFeatures loop above as a featureGroup; skip them
// here to avoid double-rendering. Childless features (D01,
// beans-n8zw) are not containers -- fall through and treat
// them as a flat leaf like any other orphan item below.
if fg, _ := classifyFeatureChild(b, children, includeDone); fg != nil {
continue
}
}
// Skip if already under a milestone
if underMilestone[b.ID] {
continue
Expand All @@ -200,10 +247,11 @@ func buildRoadmap(allBeans []*bean.Bean, includeDone bool, statusFilter, noStatu

// Build unscheduled group if there's content
var unscheduled *unscheduledGroup
if len(unscheduledEpics) > 0 || len(orphanItems) > 0 {
if len(unscheduledEpics) > 0 || len(unscheduledFeatures) > 0 || len(orphanItems) > 0 {
unscheduled = &unscheduledGroup{
Epics: unscheduledEpics,
Other: orphanItems,
Epics: unscheduledEpics,
Features: unscheduledFeatures,
Other: orphanItems,
}
}

Expand All @@ -222,47 +270,114 @@ func buildMilestoneGroup(m *bean.Bean, children map[string][]*bean.Bean, include

// Separate epics from other items
var epics []*bean.Bean

var rest []*bean.Bean
for _, child := range directChildren {
if child.Type == "epic" {
epics = append(epics, child)
} else {
rest = append(rest, child)
}
}

// Build epic groups
for _, epic := range epics {
epicItems := filterChildren(children[epic.ID], includeDone)
// Only include epics that have visible children
if len(epicItems) > 0 {
sortByTypeThenStatus(epicItems, cfg)
group.Epics = append(group.Epics, epicGroup{Epic: epic, Items: epicItems})
eg := buildEpicGroup(epic, children, includeDone)
if len(eg.Items) > 0 || len(eg.Features) > 0 {
group.Epics = append(group.Epics, eg)
}
}

// Build "Other" list: direct children that are not epics
// (With single parent enforcement, items can't be both under an epic and directly under the milestone)
var other []*bean.Bean
for _, child := range directChildren {
if child.Type == "epic" {
continue
// Split the milestone's non-epic direct children into leaf items and
// feature-typed children (which need their own recursive resolution).
other, featureChildren := splitByContainerType(rest)

for _, feature := range featureChildren {
fg, leaf := classifyFeatureChild(feature, children, includeDone)
if fg != nil {
group.Features = append(group.Features, *fg)
}
if leaf != nil {
// Childless feature (D01, beans-n8zw): not a container, render
// as a flat leaf alongside the milestone's other direct items.
other = append(other, leaf)
}
}

// Filter the remaining flat "Other" items by done status.
var filteredOther []*bean.Bean
for _, child := range other {
if includeDone || !cfg.IsArchiveStatus(child.Status) {
other = append(other, child)
filteredOther = append(filteredOther, child)
}
}

// Sort epics by their epic's title
// Sort epics and features by their title
sort.Slice(group.Epics, func(i, j int) bool {
return group.Epics[i].Epic.Title < group.Epics[j].Epic.Title
})
sort.Slice(group.Features, func(i, j int) bool {
return group.Features[i].Feature.Title < group.Features[j].Feature.Title
})

// Sort other items
sortByTypeThenStatus(other, cfg)
group.Other = other
sortByTypeThenStatus(filteredOther, cfg)
group.Other = filteredOther

return group
}

// buildEpicGroup builds an epic group: its direct leaf children plus a
// recursively-resolved featureGroup for each direct feature child.
func buildEpicGroup(epic *bean.Bean, children map[string][]*bean.Bean, includeDone bool) epicGroup {
leafs, featureChildren := splitByContainerType(children[epic.ID])

eg := epicGroup{Epic: epic}
for _, feature := range featureChildren {
fg, leaf := classifyFeatureChild(feature, children, includeDone)
if fg != nil {
eg.Features = append(eg.Features, *fg)
}
if leaf != nil {
// Childless feature (D01, beans-n8zw): not a container, render
// as a flat leaf alongside the epic's other direct items.
leafs = append(leafs, leaf)
}
}

leafItems := filterChildren(leafs, includeDone)
sortByTypeThenStatus(leafItems, cfg)
eg.Items = leafItems

sort.Slice(eg.Features, func(i, j int) bool {
return eg.Features[i].Feature.Title < eg.Features[j].Feature.Title
})
return eg
}

// classifyFeatureChild resolves a direct feature-typed child bean per D01
// (beans-n8zw): a feature is a container IFF it has >=1 leaf descendant
// (collectLeafDescendants, respecting includeDone). If it has descendants,
// the resolved featureGroup is returned for container rendering (existing
// behavior, unchanged). If it has none, the feature bean itself is returned
// as leaf so the caller can fold it into its own flat-leaf list -- and go
// through the exact same archive-status filtering every other leaf in that
// list goes through, instead of being silently dropped.
func classifyFeatureChild(feature *bean.Bean, children map[string][]*bean.Bean, includeDone bool) (fg *featureGroup, leaf *bean.Bean) {
built := buildFeatureGroup(feature, children, includeDone)
if len(built.Items) > 0 {
return &built, nil
}
return nil, feature
}

// buildFeatureGroup builds a feature group: all leaf descendants found
// anywhere beneath the feature, flattened and sorted.
func buildFeatureGroup(feature *bean.Bean, children map[string][]*bean.Bean, includeDone bool) featureGroup {
items := collectLeafDescendants(feature.ID, children, includeDone)
sortByTypeThenStatus(items, cfg)
return featureGroup{Feature: feature, Items: items}
}

// filterChildren filters children based on done status.
func filterChildren(children []*bean.Bean, includeDone bool) []*bean.Bean {
if includeDone {
Expand All @@ -281,6 +396,51 @@ func filterChildren(children []*bean.Bean, includeDone bool) []*bean.Bean {
return filtered
}

// splitByContainerType separates a bean's direct children into leafs
// (anything that isn't a feature) and feature-typed children.
func splitByContainerType(beans []*bean.Bean) (leafs []*bean.Bean, features []*bean.Bean) {
for _, b := range beans {
if b.Type == "feature" {
features = append(features, b)
} else {
leafs = append(leafs, b)
}
}
return leafs, features
}

// collectLeafDescendants recursively walks everything below parentID and
// returns the leaf beans found at any depth, flattened. Feature-typed
// descendants are transparent containers: their own children are walked
// too, but the feature bean itself is never included in the result.
// beans.yml's ValidateParent forbids feature-under-feature via the CLI, so
// this only recurses more than one level on hand-edited data -- the
// visited guard exists purely so a hand-authored parent cycle can't crash
// roadmap with a stack overflow (the old, non-recursive code was immune).
func collectLeafDescendants(parentID string, children map[string][]*bean.Bean, includeDone bool) []*bean.Bean {
return collectLeafDescendantsVisited(parentID, children, includeDone, map[string]bool{})
}

func collectLeafDescendantsVisited(parentID string, children map[string][]*bean.Bean, includeDone bool, visited map[string]bool) []*bean.Bean {
if visited[parentID] {
return nil
}
visited[parentID] = true

var leafs []*bean.Bean
for _, child := range children[parentID] {
if child.Type == "feature" {
leafs = append(leafs, collectLeafDescendantsVisited(child.ID, children, includeDone, visited)...)
continue
}
if !includeDone && cfg.IsArchiveStatus(child.Status) {
continue
}
leafs = append(leafs, child)
}
return leafs
}

// containsStatus checks if a status is in the list.
func containsStatus(statuses []string, status string) bool {
return slices.Contains(statuses, status)
Expand Down
24 changes: 22 additions & 2 deletions internal/commands/roadmap.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@
- {{typeBadge .}} {{.Title}} {{beanRef .}}
{{end -}}

{{- define "featureGroup" -}}
#### Feature: {{.Feature.Title}} {{beanRef .Feature}}
{{with firstParagraph .Feature.Body}}
> {{.}}
{{end}}

{{range .Items -}}
{{template "beanLine" .}}
{{- end}}
{{- end -}}

{{- define "epicGroup" -}}
### Epic: {{.Epic.Title}} {{beanRef .Epic}}
{{with firstParagraph .Epic.Body}}
Expand All @@ -11,6 +22,9 @@
{{range .Items -}}
{{template "beanLine" .}}
{{- end}}
{{- range .Features -}}
{{template "featureGroup" .}}
{{- end}}
{{- end -}}

# Roadmap
Expand All @@ -22,8 +36,11 @@
{{range .Epics -}}
{{template "epicGroup" .}}
{{- end}}
{{- range .Features -}}
{{template "featureGroup" .}}
{{- end}}
{{- if .Other}}
{{- if len .Epics}}
{{- if or (len .Epics) (len .Features)}}
### Miscellaneous
{{end}}

Expand All @@ -39,8 +56,11 @@
{{- range .Unscheduled.Epics -}}
{{template "epicGroup" .}}
{{- end}}
{{- range .Unscheduled.Features -}}
{{template "featureGroup" .}}
{{- end}}
{{- if .Unscheduled.Other}}
{{- if len .Unscheduled.Epics}}
{{- if or (len .Unscheduled.Epics) (len .Unscheduled.Features)}}
### Miscellaneous
{{end}}

Expand Down
Loading