fix(mol): substitute title/desc vars in root molecule bead
When pouring a formula with `title` and `desc` variables defined,
the root molecule's title and description now use {{title}} and
{{desc}} placeholders that get substituted during pour.
Previously, the root was always assigned the formula name and static
description, ignoring these common variables. Child beads correctly
substituted variables, but the root did not.
Fixes #852
This commit is contained in:
committed by
Steve Yegge
parent
5619dc0798
commit
fc8437ed08
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user