Adds closed_by_session tracking for entity CV building per Gas Town decision 009-session-events-architecture.md. Changes: - Add ClosedBySession field to Issue struct - Add closed_by_session column to issues table (migration 034) - Add --session flag to bd close command - Support CLAUDE_SESSION_ID env var as fallback - Add --session flag to bd update for status=closed - Display closed_by_session in bd show output - Update Storage interface to include session parameter in CloseIssue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
157 lines
4.6 KiB
Go
157 lines
4.6 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// epicTestHelper provides test setup and assertion methods
|
|
type epicTestHelper struct {
|
|
t *testing.T
|
|
ctx context.Context
|
|
store *SQLiteStorage
|
|
}
|
|
|
|
func newEpicTestHelper(t *testing.T, store *SQLiteStorage) *epicTestHelper {
|
|
return &epicTestHelper{t: t, ctx: context.Background(), store: store}
|
|
}
|
|
|
|
func (h *epicTestHelper) createEpic(title string) *types.Issue {
|
|
epic := &types.Issue{
|
|
Title: title,
|
|
Description: "Epic for testing",
|
|
Status: types.StatusOpen,
|
|
Priority: 1,
|
|
IssueType: types.TypeEpic,
|
|
}
|
|
if err := h.store.CreateIssue(h.ctx, epic, "test-user"); err != nil {
|
|
h.t.Fatalf("CreateIssue (epic) failed: %v", err)
|
|
}
|
|
return epic
|
|
}
|
|
|
|
func (h *epicTestHelper) createTask(title string) *types.Issue {
|
|
task := &types.Issue{
|
|
Title: title,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
}
|
|
if err := h.store.CreateIssue(h.ctx, task, "test-user"); err != nil {
|
|
h.t.Fatalf("CreateIssue (%s) failed: %v", title, err)
|
|
}
|
|
return task
|
|
}
|
|
|
|
func (h *epicTestHelper) addParentChildDependency(childID, parentID string) {
|
|
dep := &types.Dependency{
|
|
IssueID: childID,
|
|
DependsOnID: parentID,
|
|
Type: types.DepParentChild,
|
|
}
|
|
if err := h.store.AddDependency(h.ctx, dep, "test-user"); err != nil {
|
|
h.t.Fatalf("AddDependency failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func (h *epicTestHelper) closeIssue(id, reason string) {
|
|
if err := h.store.CloseIssue(h.ctx, id, reason, "test-user", ""); err != nil {
|
|
h.t.Fatalf("CloseIssue (%s) failed: %v", id, err)
|
|
}
|
|
}
|
|
|
|
func (h *epicTestHelper) getEligibleEpics() []*types.EpicStatus {
|
|
epics, err := h.store.GetEpicsEligibleForClosure(h.ctx)
|
|
if err != nil {
|
|
h.t.Fatalf("GetEpicsEligibleForClosure failed: %v", err)
|
|
}
|
|
return epics
|
|
}
|
|
|
|
func (h *epicTestHelper) findEpic(epics []*types.EpicStatus, epicID string) (*types.EpicStatus, bool) {
|
|
for _, e := range epics {
|
|
if e.Epic.ID == epicID {
|
|
return e, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (h *epicTestHelper) assertEpicStats(epic *types.EpicStatus, totalChildren, closedChildren int, eligible bool, desc string) {
|
|
if epic.TotalChildren != totalChildren {
|
|
h.t.Errorf("%s: Expected %d total children, got %d", desc, totalChildren, epic.TotalChildren)
|
|
}
|
|
if epic.ClosedChildren != closedChildren {
|
|
h.t.Errorf("%s: Expected %d closed children, got %d", desc, closedChildren, epic.ClosedChildren)
|
|
}
|
|
if epic.EligibleForClose != eligible {
|
|
h.t.Errorf("%s: Expected eligible=%v, got %v", desc, eligible, epic.EligibleForClose)
|
|
}
|
|
}
|
|
|
|
func (h *epicTestHelper) assertEpicNotFound(epics []*types.EpicStatus, epicID string, desc string) {
|
|
if _, found := h.findEpic(epics, epicID); found {
|
|
h.t.Errorf("%s: Epic %s should not be in results", desc, epicID)
|
|
}
|
|
}
|
|
|
|
func (h *epicTestHelper) assertEpicFound(epics []*types.EpicStatus, epicID string, desc string) *types.EpicStatus {
|
|
epic, found := h.findEpic(epics, epicID)
|
|
if !found {
|
|
h.t.Fatalf("%s: Epic %s not found in results", desc, epicID)
|
|
}
|
|
return epic
|
|
}
|
|
|
|
func TestGetEpicsEligibleForClosure(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
h := newEpicTestHelper(t, store)
|
|
epic := h.createEpic("Test Epic")
|
|
task1 := h.createTask("Task 1")
|
|
task2 := h.createTask("Task 2")
|
|
h.addParentChildDependency(task1.ID, epic.ID)
|
|
h.addParentChildDependency(task2.ID, epic.ID)
|
|
|
|
// Test 1: Epic with open children should NOT be eligible
|
|
epics := h.getEligibleEpics()
|
|
if len(epics) == 0 {
|
|
t.Fatal("Expected at least one epic")
|
|
}
|
|
e := h.assertEpicFound(epics, epic.ID, "All children open")
|
|
h.assertEpicStats(e, 2, 0, false, "All children open")
|
|
|
|
// Test 2: Close one task
|
|
h.closeIssue(task1.ID, "Done")
|
|
epics = h.getEligibleEpics()
|
|
e = h.assertEpicFound(epics, epic.ID, "One child closed")
|
|
h.assertEpicStats(e, 2, 1, false, "One child closed")
|
|
|
|
// Test 3: Close second task - epic should be eligible
|
|
h.closeIssue(task2.ID, "Done")
|
|
epics = h.getEligibleEpics()
|
|
e = h.assertEpicFound(epics, epic.ID, "All children closed")
|
|
h.assertEpicStats(e, 2, 2, true, "All children closed")
|
|
|
|
// Test 4: Close the epic - should no longer appear
|
|
h.closeIssue(epic.ID, "All tasks complete")
|
|
epics = h.getEligibleEpics()
|
|
h.assertEpicNotFound(epics, epic.ID, "Closed epic")
|
|
}
|
|
|
|
func TestGetEpicsEligibleForClosureWithNoChildren(t *testing.T) {
|
|
store, cleanup := setupTestDB(t)
|
|
defer cleanup()
|
|
|
|
h := newEpicTestHelper(t, store)
|
|
epic := h.createEpic("Childless Epic")
|
|
epics := h.getEligibleEpics()
|
|
|
|
// Should find the epic but it should NOT be eligible
|
|
e := h.assertEpicFound(epics, epic.ID, "No children")
|
|
h.assertEpicStats(e, 0, 0, false, "No children")
|
|
}
|