Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion internal/bd/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,12 @@ type EpicStatus struct {
ClosedChildren int `json:"closed_children"`
}

// EpicRef is the epic identity inside an EpicStatus row.
// EpicRef is the epic identity inside an EpicStatus row. Title is bd's own epic
// title, carried so a consumer naming an epic doesn't re-read the DAG for it —
// counts' epics[] rows render it (sd-3wp.2).
type EpicRef struct {
ID string `json:"id"`
Title string `json:"title"`
Status Status `json:"status"`
}

Expand Down
78 changes: 69 additions & 9 deletions internal/counts/counts.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ type Row struct {
// bead Next names when Next.Reason == "claimed"), or nil when nothing is
// in_progress.
Claimed *Ref `json:"claimed"`
// Roadmap is the current epic's position among ALL roadmap ids, closed
// included — the "epic 3 of 7" a banner states. nil when there is no roadmap
// or no live epic (the same two conditions that null Epics).
Roadmap *RoadmapPos `json:"roadmap"`
}

// RoadmapPos is Epics[0]'s 1-based position K among the roadmap's N ordered epic
// ids. N counts every id the roadmap names, including closed epics, so K/N reads
// as progress through the project — Epics[] indexes only the live ones and cannot
// express it.
type RoadmapPos struct {
K int `json:"k"`
N int `json:"n"`
}

// issueTypeEpic is bd's issue_type value for an epic — used to exclude epics
Expand All @@ -70,10 +83,22 @@ const issueTypeEpic = "epic"
// — mirroring Row's own repo-level bw rule.
type EpicRow struct {
ID string `json:"id"`
BH int `json:"bh"`
BO int `json:"bo"`
BW int `json:"bw"`
BB int `json:"bb"`
// Title is bd's epic title, from the same EpicStatus read the buckets derive
// from — so a consumer renders the epic by name without a second bd fork.
// Empty when bd omitted it.
Title string `json:"title"`
BH int `json:"bh"`
BO int `json:"bo"`
BW int `json:"bw"`
BB int `json:"bb"`
// BCl and N are bd's OWN child roll-up for this epic — closed children and
// total children, straight from the EpicStatus read. The four lane buckets
// above partition only LIVE children (bd list omits closed), so they cannot
// express "how far through this epic are we"; these two can, and a consumer
// renders ✓/a progress bar from them without a second bd fork. They are bd's
// count, not ours: a nested epic child counts here and not in the buckets.
BCl int `json:"bcl"`
N int `json:"n"`
}

// Next is the what's-next cascade's pick: the bead id/title to work next, and which
Expand Down Expand Up @@ -130,13 +155,16 @@ func computeRow(ctx context.Context, src source, root string) (Row, error) {
lanes := insight.Lanes(issues, deps)
bh, bo, bb := laneCounts(lanes)

roadmap := strandmd.Roadmap(root)
var epicRows []EpicRow
var next *Next
var claimed *Ref
var pos *RoadmapPos
if epics, err := src.EpicStatus(ctx); err == nil {
liveEpics := liveRoadmapEpics(strandmd.Roadmap(root), epics)
epicRows = epicBuckets(liveEpics, issues, lanes)
liveEpics := liveRoadmapEpics(roadmap, epics)
epicRows = epicBuckets(liveEpics, epicMeta(epics), issues, lanes)
next, claimed = pickNext(issues, lanes, currentEpicID(liveEpics), liveEpics)
pos = roadmapPos(roadmap, currentEpicID(liveEpics))
} else {
// EpicStatus read failed: degrade epics to nil (no epic data to bucket) and
// run the cascade with no epic info — rungs 1 and 3 don't need it, so next
Expand All @@ -148,7 +176,8 @@ func computeRow(ctx context.Context, src source, root string) (Row, error) {
BH: bh, BO: bo, BW: stats.InProgress, BB: bb,
BCl: stats.Closed, BDf: stats.Deferred,
Epics: epicRows, Next: next, Claimed: claimed,
TS: lastTouched(root),
Roadmap: pos,
TS: lastTouched(root),
}, nil
}

Expand Down Expand Up @@ -204,21 +233,52 @@ func currentEpicID(liveEpics []string) string {
return liveEpics[0]
}

// epicMeta maps epic id → the EpicStatus facts EpicRow carries beyond its own lane
// buckets: bd's title and bd's closed/total child roll-up. Built over the WHOLE
// EpicStatus set, not only the live epics, so a closed epic stays reachable for any
// consumer that wants to name it. An epic bd reported no title for maps to "",
// which renders as an absent title rather than an error.
func epicMeta(epics []bd.EpicStatus) map[string]bd.EpicStatus {
meta := make(map[string]bd.EpicStatus, len(epics))
for _, e := range epics {
meta[e.Epic.ID] = e
}
return meta
}

// roadmapPos locates currentEpic among ALL roadmap ids — closed epics included, so
// K/N reads as progress through the project rather than through what happens to be
// live. nil when there is no roadmap, no current epic, or the current epic somehow
// is not on the roadmap (impossible today: liveRoadmapEpics only ever returns ids
// it walked the roadmap to find, but the guard keeps the two independent).
func roadmapPos(roadmap []string, currentEpic string) *RoadmapPos {
if len(roadmap) == 0 || currentEpic == "" {
return nil
}
for i, id := range roadmap {
if id == currentEpic {
return &RoadmapPos{K: i + 1, N: len(roadmap)}
}
}
return nil
}

// epicBuckets builds one ◆○◐● bucket row per live roadmap epic, in liveEpics' order
// (roadmap order), over each epic's DIRECT children only (Issue.Parent == the
// epic's id) — a nested epic child (issue_type=="epic") never counts toward its
// parent's buckets. bw is the raw in_progress status total for that epic's
// children, overlapping bh for a gated in-progress child (mirrors laneCounts' own
// repo-level bw rule). nil when liveEpics is empty, matching the JSON schema's
// "epics: null" for a repo with no live roadmap epic.
func epicBuckets(liveEpics []string, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow {
func epicBuckets(liveEpics []string, meta map[string]bd.EpicStatus, issues []bd.Issue, lanes map[string]insight.Lane) []EpicRow {
if len(liveEpics) == 0 {
return nil
}
rows := make([]EpicRow, len(liveEpics))
idx := make(map[string]int, len(liveEpics))
for i, id := range liveEpics {
rows[i] = EpicRow{ID: id}
m := meta[id]
rows[i] = EpicRow{ID: id, Title: m.Epic.Title, BCl: m.ClosedChildren, N: m.TotalChildren}
idx[id] = i
}
for i := range issues {
Expand Down
108 changes: 101 additions & 7 deletions internal/counts/counts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,11 @@ func TestEpicBuckets(t *testing.T) {
{ID: "e3.open", Parent: "e3", Status: bd.StatusOpen}, // e3 not in liveEpics: must be absent
}
lanes := insight.Lanes(issues, nil)
got := epicBuckets([]string{"e1", "e2"}, issues, lanes)
meta := map[string]bd.EpicStatus{
"e1": {Epic: bd.EpicRef{ID: "e1", Title: "First epic"}, TotalChildren: 9, ClosedChildren: 3},
"e3": {Epic: bd.EpicRef{ID: "e3", Title: "Not live"}},
}
got := epicBuckets([]string{"e1", "e2"}, meta, issues, lanes)
if len(got) != 2 {
t.Fatalf("epicBuckets returned %d rows, want 2 (roadmap order, e3 excluded): %+v", len(got), got)
}
Expand All @@ -314,12 +318,28 @@ func TestEpicBuckets(t *testing.T) {
if e2 := got[1]; e2.BH != 0 || e2.BO != 1 || e2.BW != 0 || e2.BB != 0 {
t.Errorf("e2 buckets = %+v, want bo=1 only", e2)
}
// Title comes from the EpicStatus lookup, and an epic bd gave no title for
// renders as empty — not as a missing row.
if got[0].Title != "First epic" {
t.Errorf("e1 title = %q, want %q", got[0].Title, "First epic")
}
if got[1].Title != "" {
t.Errorf("e2 title = %q, want empty — no title in the lookup", got[1].Title)
}
// bcl/n are bd's own roll-up, carried verbatim — not re-derived from the
// lane buckets, which see only live children.
if got[0].BCl != 3 || got[0].N != 9 {
t.Errorf("e1 roll-up = bcl %d n %d, want 3/9 — bd's own closed/total children", got[0].BCl, got[0].N)
}
if got[1].BCl != 0 || got[1].N != 0 {
t.Errorf("e2 roll-up = bcl %d n %d, want 0/0 — absent from the lookup", got[1].BCl, got[1].N)
}
}

// TestEpicBucketsNilForNoLiveEpics: nil roadmap (or an all-ghost/all-closed one) →
// nil, matching the JSON schema's "epics: null" for a repo with no roadmap.
func TestEpicBucketsNilForNoLiveEpics(t *testing.T) {
if got := epicBuckets(nil, nil, nil); got != nil {
if got := epicBuckets(nil, nil, nil, nil); got != nil {
t.Errorf("epicBuckets(nil roadmap) = %v, want nil", got)
}
}
Expand Down Expand Up @@ -378,24 +398,90 @@ func TestCurrentEpicID(t *testing.T) {
// TestComputeRowAssemblesEpicsNextAndClaimed: a real ROADMAP.md resolves through
// strandmd.Roadmap, the epic buckets and the cascade both derive from the SAME
// issues/lanes computeRow already fetched (zero new bd execs).
// TestRoadmapPos pins K/N against ALL roadmap ids — a closed epic still occupies a
// slot, which is the whole reason this field exists rather than an epics[] index.
func TestRoadmapPos(t *testing.T) {
roadmap := []string{"e1", "e2", "e3"} // e1 closed: still counted, still shifts e2 to slot 2
tests := []struct {
name string
roadmap []string
currentEpic string
want *RoadmapPos
}{
{"current epic mid-roadmap", roadmap, "e2", &RoadmapPos{K: 2, N: 3}},
{"first slot", roadmap, "e1", &RoadmapPos{K: 1, N: 3}},
{"last slot", roadmap, "e3", &RoadmapPos{K: 3, N: 3}},
{"no roadmap", nil, "e2", nil},
{"no current epic", roadmap, "", nil},
{"current epic off the roadmap", roadmap, "e9", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := roadmapPos(tt.roadmap, tt.currentEpic)
switch {
case tt.want == nil && got != nil:
t.Fatalf("roadmapPos = %+v, want nil", got)
case tt.want != nil && got == nil:
t.Fatalf("roadmapPos = nil, want %+v", tt.want)
case tt.want != nil && (got.K != tt.want.K || got.N != tt.want.N):
t.Errorf("roadmapPos = %+v, want %+v", got, tt.want)
}
})
}
}

// TestEpicMetaCoversWholeSet: the lookup carries every epic bd reported, not only
// the live ones — a closed epic's title and roll-up stay reachable for any consumer
// that wants to name it.
func TestEpicMetaCoversWholeSet(t *testing.T) {
got := epicMeta([]bd.EpicStatus{
{Epic: bd.EpicRef{ID: "e1", Title: "Closed one", Status: bd.StatusClosed}, TotalChildren: 4, ClosedChildren: 4},
{Epic: bd.EpicRef{ID: "e2", Title: "Live one", Status: bd.StatusOpen}, TotalChildren: 3, ClosedChildren: 1},
{Epic: bd.EpicRef{ID: "e3", Status: bd.StatusOpen}},
})
if len(got) != 3 {
t.Fatalf("epicMeta = %+v, want all three ids", got)
}
if got["e1"].Epic.Title != "Closed one" || got["e1"].ClosedChildren != 4 {
t.Errorf("e1 = %+v, want the closed epic's title and roll-up", got["e1"])
}
if got["e2"].TotalChildren != 3 || got["e2"].ClosedChildren != 1 {
t.Errorf("e2 roll-up = %+v, want 1/3", got["e2"])
}
if got["e3"].Epic.Title != "" {
t.Errorf("e3 title = %q, want empty", got["e3"].Epic.Title)
}
}

func TestComputeRowAssemblesEpicsNextAndClaimed(t *testing.T) {
src := &fakeSource{
issues: []bd.Issue{
{ID: "e2.open", Parent: "e2", Status: bd.StatusOpen, Priority: new(1)},
{ID: "e2.ip", Parent: "e2", Status: bd.StatusInProgress, Priority: new(0)}, // rung 1 winner
},
epics: []bd.EpicStatus{
{Epic: bd.EpicRef{ID: "e2", Status: bd.StatusOpen}, TotalChildren: 2, ClosedChildren: 0},
{Epic: bd.EpicRef{ID: "e2", Title: "Second epic", Status: bd.StatusOpen}, TotalChildren: 5, ClosedChildren: 3},
},
}
root := writeRoadmapDir(t, "## Epics\n1. Title → e2\n")
// e1 is on the roadmap but absent from the EpicStatus set (a ghost id), so the
// live list starts at e2 while the roadmap position still counts e1: 2 of 2.
root := writeRoadmapDir(t, "## Epics\n1. First → e1\n2. Title → e2\n")
row, err := computeRow(context.Background(), src, root)
if err != nil {
t.Fatalf("computeRow: %v", err)
}
if len(row.Epics) != 1 || row.Epics[0].ID != "e2" || row.Epics[0].BO != 1 {
t.Errorf("Epics = %+v, want one e2 row with bo=1", row.Epics)
}
if len(row.Epics) == 1 && row.Epics[0].Title != "Second epic" {
t.Errorf("Epics[0].Title = %q, want %q — carried from the EpicStatus read", row.Epics[0].Title, "Second epic")
}
if len(row.Epics) == 1 && (row.Epics[0].BCl != 3 || row.Epics[0].N != 5) {
t.Errorf("Epics[0] roll-up = bcl %d n %d, want 3/5", row.Epics[0].BCl, row.Epics[0].N)
}
if row.Roadmap == nil || row.Roadmap.K != 2 || row.Roadmap.N != 2 {
t.Errorf("Roadmap = %+v, want k=2 n=2 — the current epic's slot among ALL roadmap ids", row.Roadmap)
}
if row.Next == nil || row.Next.ID != "e2.ip" || row.Next.Reason != "claimed" {
t.Errorf("Next = %+v, want e2.ip/claimed", row.Next)
}
Expand Down Expand Up @@ -426,6 +512,9 @@ func TestComputeRowEpicStatusFailureDegradesEpicsAndNext(t *testing.T) {
if row.Next == nil || row.Next.ID != "b1" || row.Next.Reason != "waiting-on-dk" {
t.Errorf("Next = %+v, want b1/waiting-on-dk — rung 3 is still reachable without epic info", row.Next)
}
if row.Roadmap != nil {
t.Errorf("Roadmap = %+v, want nil on EpicStatus failure — no current epic to place", row.Roadmap)
}
}

// --- JSON shape: additive fields, explicit null, existing keys untouched ---
Expand All @@ -443,7 +532,7 @@ func TestRowJSONShapeExplicitNulls(t *testing.T) {
if err := json.Unmarshal(b, &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"epics", "next", "claimed"} {
for _, key := range []string{"epics", "next", "claimed", "roadmap"} {
raw, ok := m[key]
if !ok {
t.Fatalf("key %q missing from JSON, want present as explicit null", key)
Expand All @@ -469,9 +558,10 @@ func TestRowJSONShapeExplicitNulls(t *testing.T) {
// TestRowJSONShapePopulated: a populated Row round-trips epics/next/claimed intact.
func TestRowJSONShapePopulated(t *testing.T) {
row := Row{
Epics: []EpicRow{{ID: "e1", BH: 1}},
Epics: []EpicRow{{ID: "e1", Title: "Epic one", BH: 1, BCl: 4, N: 9}},
Next: &Next{ID: "n1", Title: "t", Reason: "claimed"},
Claimed: &Ref{ID: "n1", Title: "t"},
Roadmap: &RoadmapPos{K: 2, N: 7},
}
b, err := json.Marshal(row)
if err != nil {
Expand All @@ -481,9 +571,13 @@ func TestRowJSONShapePopulated(t *testing.T) {
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got.Epics) != 1 || got.Epics[0].ID != "e1" {
if len(got.Epics) != 1 || got.Epics[0].ID != "e1" || got.Epics[0].Title != "Epic one" ||
got.Epics[0].BCl != 4 || got.Epics[0].N != 9 {
t.Errorf("Epics round-trip = %+v", got.Epics)
}
if got.Roadmap == nil || got.Roadmap.K != 2 || got.Roadmap.N != 7 {
t.Errorf("Roadmap round-trip = %+v, want k=2 n=7", got.Roadmap)
}
if got.Next == nil || got.Next.Reason != "claimed" {
t.Errorf("Next round-trip = %+v", got.Next)
}
Expand Down
Loading