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
28 changes: 16 additions & 12 deletions internal/engine/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -357,20 +357,24 @@ func (rb *ReportBuilder) buildTimeline(reqID string, stories []state.Story) []Ti
storyIDSet[s.ID] = s.Title
}

// Previously this loop issued one List per (event-type × story-id) pair
// — 5 × len(stories) sequential queries. On JSONL stores that's 5N file
// reads; on SQLite it's 5N transactions. Issue ONE List per event type
// (no StoryID filter), then filter against storyIDSet in memory: 5
// queries total regardless of story count.
for _, evtType := range storyEventTypes {
for storyID, storyTitle := range storyIDSet {
evts, _ := rb.es.List(state.EventFilter{
Type: evtType,
StoryID: storyID,
})
for _, evt := range evts {
entries = append(entries, TimelineEntry{
Timestamp: evt.Timestamp,
EventType: string(evt.Type),
StoryID: storyID,
Description: rb.describeStoryEvent(evt.Type, storyTitle),
})
evts, _ := rb.es.List(state.EventFilter{Type: evtType})
for _, evt := range evts {
storyTitle, owned := storyIDSet[evt.StoryID]
if !owned {
continue
}
entries = append(entries, TimelineEntry{
Timestamp: evt.Timestamp,
EventType: string(evt.Type),
StoryID: evt.StoryID,
Description: rb.describeStoryEvent(evt.Type, storyTitle),
})
}
}

Expand Down
34 changes: 34 additions & 0 deletions internal/engine/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,40 @@ func setupReportStores(t *testing.T) (state.EventStore, *state.SQLiteStore, func
return es, ps, cleanup
}

// TestReportBuilder_Build_TimelineFiltersByStoryOwnership guards the
// post-N+1 path: buildTimeline issues one List per event type (no StoryID
// filter) and screens via storyIDSet in memory. The regression to prevent
// is "events from stories belonging to OTHER requirements leak into the
// timeline." Seed a STORY_MERGED for a foreign story id, then assert it is
// NOT in the report.
func TestReportBuilder_Build_TimelineFiltersByStoryOwnership(t *testing.T) {
es, ps, cleanup := setupReportStores(t)
defer cleanup()

// Foreign story id — does NOT appear in stories(req-001).
foreignMerged := state.NewEvent(state.EventStoryMerged, "merger", "FOREIGN-STORY-Z", nil)
if err := es.Append(foreignMerged); err != nil {
t.Fatalf("append foreign event: %v", err)
}
if err := ps.Project(foreignMerged); err != nil {
t.Fatalf("project foreign event: %v", err)
}

cfg := config.DefaultConfig()
rb := engine.NewReportBuilder(es, ps, cfg)

report, err := rb.Build("req-001")
if err != nil {
t.Fatalf("Build: %v", err)
}

for _, entry := range report.Timeline {
if entry.StoryID == "FOREIGN-STORY-Z" {
t.Fatalf("foreign story %q leaked into timeline (in-memory filter regression)", entry.StoryID)
}
}
}

func TestReportBuilder_Build_BasicFields(t *testing.T) {
es, ps, cleanup := setupReportStores(t)
defer cleanup()
Expand Down
Loading