diff --git a/cmd/bd/cook.go b/cmd/bd/cook.go index a803992c..efc2d0c5 100644 --- a/cmd/bd/cook.go +++ b/cmd/bd/cook.go @@ -443,11 +443,25 @@ func cookFormulaToSubgraph(f *formula.Formula, protoID string) (*TemplateSubgrap var issues []*types.Issue var deps []*types.Dependency + // Determine root title: use {{title}} placeholder if the variable is defined, + // otherwise fall back to formula name (GH#852) + rootTitle := f.Formula + if _, hasTitle := f.Vars["title"]; hasTitle { + rootTitle = "{{title}}" + } + + // Determine root description: use {{desc}} placeholder if the variable is defined, + // otherwise fall back to formula description (GH#852) + rootDesc := f.Description + if _, hasDesc := f.Vars["desc"]; hasDesc { + rootDesc = "{{desc}}" + } + // Create root proto epic rootIssue := &types.Issue{ ID: protoID, - Title: f.Formula, // Title is the original formula name - Description: f.Description, + Title: rootTitle, + Description: rootDesc, Status: types.StatusOpen, Priority: 2, IssueType: types.TypeEpic, @@ -783,11 +797,25 @@ func cookFormula(ctx context.Context, s storage.Storage, f *formula.Formula, pro var deps []*types.Dependency var labels []struct{ issueID, label string } + // Determine root title: use {{title}} placeholder if the variable is defined, + // otherwise fall back to formula name (GH#852) + rootTitle := f.Formula + if _, hasTitle := f.Vars["title"]; hasTitle { + rootTitle = "{{title}}" + } + + // Determine root description: use {{desc}} placeholder if the variable is defined, + // otherwise fall back to formula description (GH#852) + rootDesc := f.Description + if _, hasDesc := f.Vars["desc"]; hasDesc { + rootDesc = "{{desc}}" + } + // Create root proto epic using provided protoID (may include prefix) rootIssue := &types.Issue{ ID: protoID, - Title: f.Formula, // Title is the original formula name - Description: f.Description, + Title: rootTitle, + Description: rootDesc, Status: types.StatusOpen, Priority: 2, IssueType: types.TypeEpic, diff --git a/cmd/bd/mol_test.go b/cmd/bd/mol_test.go index b284ec7f..afe8232a 100644 --- a/cmd/bd/mol_test.go +++ b/cmd/bd/mol_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/steveyegge/beads/internal/formula" "github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/types" ) @@ -2627,3 +2628,206 @@ func TestFormatBondType(t *testing.T) { }) } } + +// TestPourRootTitleDescSubstitution verifies that the root molecule's title and description +// are substituted with {{title}} and {{desc}} variables when pouring a formula. +// This is a tracer bullet test for GitHub issue #852: +// https://github.com/steveyegge/beads/issues/852 +func TestPourRootTitleDescSubstitution(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Create a formula that has title and desc variables + f := &formula.Formula{ + Formula: "mol-task", + Description: "Standard task workflow for 2-8 hour work...", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "title": { + Description: "Task title", + Required: true, + }, + "desc": { + Description: "Task description", + Required: false, + Default: "No description provided", + }, + }, + Steps: []*formula.Step{ + {ID: "plan", Title: "Plan: {{title}}", Type: "task"}, + {ID: "implement", Title: "Implement: {{title}}", Type: "task", DependsOn: []string{"plan"}}, + {ID: "verify", Title: "Verify: {{title}}", Type: "task", DependsOn: []string{"implement"}}, + {ID: "review", Title: "Review: {{title}}", Type: "task", DependsOn: []string{"verify"}}, + }, + } + + // Cook the formula to a subgraph (in-memory, no DB) + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + // Spawn with title and desc variables + vars := map[string]string{ + "title": "My Task", + "desc": "My description", + } + + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + // Get the spawned root issue + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // BUG: The root title should contain "My Task" but currently contains "mol-task" + // because cookFormulaToSubgraph sets root.Title = f.Formula instead of using + // a template that includes {{title}}. + if !strings.Contains(spawnedRoot.Title, "My Task") { + t.Errorf("Root title should contain 'My Task' from variable substitution, got: %q", spawnedRoot.Title) + } + + // BUG: The root description should contain "My description" but currently + // contains the formula's static description. + if !strings.Contains(spawnedRoot.Description, "My description") { + t.Errorf("Root description should contain 'My description' from variable substitution, got: %q", spawnedRoot.Description) + } + + // Verify child beads DO have correct substitution (this should pass) + for oldID, newID := range result.IDMapping { + if oldID == f.Formula { + continue // Skip root + } + spawned, err := s.GetIssue(ctx, newID) + if err != nil { + t.Fatalf("Failed to get spawned issue %s: %v", newID, err) + } + if !strings.Contains(spawned.Title, "My Task") { + t.Errorf("Child issue %s (from %s) title should contain 'My Task', got: %q", newID, oldID, spawned.Title) + } + } +} + +// TestPourRootTitleOnly verifies edge case: only title var defined, no desc. +// Root should use {{title}} for title, but keep formula description. +func TestPourRootTitleOnly(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Formula with only title var (no desc) + f := &formula.Formula{ + Formula: "mol-simple", + Description: "Static description that should be preserved", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "title": {Description: "Task title", Required: true}, + }, + Steps: []*formula.Step{ + {ID: "work", Title: "Do: {{title}}", Type: "task"}, + }, + } + + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + vars := map[string]string{"title": "Custom Title"} + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // Title should be substituted + if !strings.Contains(spawnedRoot.Title, "Custom Title") { + t.Errorf("Root title should contain 'Custom Title', got: %q", spawnedRoot.Title) + } + + // Description should be the static formula description (no desc var) + if spawnedRoot.Description != "Static description that should be preserved" { + t.Errorf("Root description should be static formula desc, got: %q", spawnedRoot.Description) + } +} + +// TestPourRootNoVars verifies backward compatibility: no title/desc vars defined. +// Root should use formula name and formula description (original behavior). +func TestPourRootNoVars(t *testing.T) { + ctx := context.Background() + dbPath := t.TempDir() + "/test.db" + s, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + defer s.Close() + if err := s.SetConfig(ctx, "issue_prefix", "mol"); err != nil { + t.Fatalf("Failed to set config: %v", err) + } + + // Formula with no title/desc vars (uses different var names) + f := &formula.Formula{ + Formula: "mol-release", + Description: "Release workflow for version bumps", + Version: 1, + Type: formula.TypeWorkflow, + Vars: map[string]*formula.VarDef{ + "version": {Description: "Version number", Required: true}, + }, + Steps: []*formula.Step{ + {ID: "bump", Title: "Bump to {{version}}", Type: "task"}, + {ID: "tag", Title: "Tag {{version}}", Type: "task", DependsOn: []string{"bump"}}, + }, + } + + subgraph, err := cookFormulaToSubgraphWithVars(f, f.Formula, f.Vars) + if err != nil { + t.Fatalf("Failed to cook formula: %v", err) + } + + vars := map[string]string{"version": "1.2.3"} + result, err := spawnMolecule(ctx, s, subgraph, vars, "", "test", false, "mol") + if err != nil { + t.Fatalf("spawnMolecule failed: %v", err) + } + + spawnedRoot, err := s.GetIssue(ctx, result.NewEpicID) + if err != nil { + t.Fatalf("Failed to get spawned root: %v", err) + } + + // Title should be formula name (no title var defined) + if spawnedRoot.Title != "mol-release" { + t.Errorf("Root title should be formula name 'mol-release', got: %q", spawnedRoot.Title) + } + + // Description should be formula description (no desc var defined) + if spawnedRoot.Description != "Release workflow for version bumps" { + t.Errorf("Root description should be formula desc, got: %q", spawnedRoot.Description) + } +}