feat: add dynamic molecule bonding with --ref flag (bd-xo1o.1)
Implements the Christmas Ornament pattern for patrol molecules:
- Add CloneOptions struct with ParentID and ChildRef for dynamic bonding
- Add generateBondedID() to create custom IDs like "patrol-x7k.arm-ace"
- Add --ref flag to `bd mol bond` for custom child references
- Variable substitution in childRef (e.g., "arm-{{polecat_name}}")
This enables:
bd mol bond mol-polecat-arm bd-patrol --ref arm-{{name}} --var name=ace
# Creates: bd-patrol.arm-ace, bd-patrol.arm-ace.capture, etc.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -64,7 +64,19 @@ Commands:
|
||||
// Wraps cloneSubgraph from template.go and returns InstantiateResult.
|
||||
// If ephemeral is true, spawned issues are marked for bulk deletion when closed.
|
||||
func spawnMolecule(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, vars map[string]string, assignee string, actorName string, ephemeral bool) (*InstantiateResult, error) {
|
||||
return cloneSubgraph(ctx, s, subgraph, vars, assignee, actorName, ephemeral)
|
||||
opts := CloneOptions{
|
||||
Vars: vars,
|
||||
Assignee: assignee,
|
||||
Actor: actorName,
|
||||
Wisp: ephemeral,
|
||||
}
|
||||
return cloneSubgraph(ctx, s, subgraph, opts)
|
||||
}
|
||||
|
||||
// spawnMoleculeWithOptions creates new issues from the proto using CloneOptions.
|
||||
// This allows full control over dynamic bonding, variable substitution, and wisp phase.
|
||||
func spawnMoleculeWithOptions(ctx context.Context, s storage.Storage, subgraph *MoleculeSubgraph, opts CloneOptions) (*InstantiateResult, error) {
|
||||
return cloneSubgraph(ctx, s, subgraph, opts)
|
||||
}
|
||||
|
||||
// printMoleculeTree prints the molecule structure as a tree
|
||||
|
||||
@@ -41,9 +41,18 @@ Phase control:
|
||||
--pour Force spawn as liquid (persistent), even when attaching to wisp
|
||||
--wisp Force spawn as vapor (ephemeral), even when attaching to mol
|
||||
|
||||
Dynamic bonding (Christmas Ornament pattern):
|
||||
Use --ref to specify a custom child reference with variable substitution.
|
||||
This creates IDs like "parent.child-ref" instead of random hashes.
|
||||
|
||||
Example:
|
||||
bd mol bond mol-polecat-arm bd-patrol --ref arm-{{polecat_name}} --var polecat_name=ace
|
||||
# Creates: bd-patrol.arm-ace (and children like bd-patrol.arm-ace.capture)
|
||||
|
||||
Use cases:
|
||||
- Found important bug during patrol? Use --pour to persist it
|
||||
- Need ephemeral diagnostic on persistent feature? Use --wisp
|
||||
- Spawning per-worker arms on a patrol? Use --ref for readable IDs
|
||||
|
||||
Examples:
|
||||
bd mol bond mol-feature mol-deploy # Compound proto
|
||||
@@ -51,7 +60,8 @@ Examples:
|
||||
bd mol bond mol-feature bd-abc123 # Attach proto to molecule
|
||||
bd mol bond bd-abc123 bd-def456 # Join two molecules
|
||||
bd mol bond mol-critical-bug wisp-patrol --pour # Persist found bug
|
||||
bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic`,
|
||||
bd mol bond mol-temp-check bd-feature --wisp # Ephemeral diagnostic
|
||||
bd mol bond mol-arm bd-patrol --ref arm-{{name}} --var name=ace # Dynamic child ID`,
|
||||
Args: cobra.ExactArgs(2),
|
||||
Run: runMolBond,
|
||||
}
|
||||
@@ -88,6 +98,7 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
||||
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||
wisp, _ := cmd.Flags().GetBool("wisp")
|
||||
pour, _ := cmd.Flags().GetBool("pour")
|
||||
childRef, _ := cmd.Flags().GetString("ref")
|
||||
|
||||
// Validate phase flags are not both set
|
||||
if wisp && pour {
|
||||
@@ -170,6 +181,10 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
||||
} else if pour {
|
||||
fmt.Printf(" Phase override: liquid (--pour)\n")
|
||||
}
|
||||
if childRef != "" {
|
||||
resolvedRef := substituteVariables(childRef, vars)
|
||||
fmt.Printf(" Child ref: %s (resolved: %s)\n", childRef, resolvedRef)
|
||||
}
|
||||
if aIsProto && bIsProto {
|
||||
fmt.Printf(" Result: compound proto\n")
|
||||
if customTitle != "" {
|
||||
@@ -178,13 +193,28 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
||||
if wisp || pour {
|
||||
fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n")
|
||||
}
|
||||
if childRef != "" {
|
||||
fmt.Printf(" Note: --ref ignored for proto+proto (use when bonding to molecule)\n")
|
||||
}
|
||||
} else if aIsProto || bIsProto {
|
||||
fmt.Printf(" Result: spawn proto, attach to molecule\n")
|
||||
if !wisp && !pour {
|
||||
fmt.Printf(" Phase: follows target's phase\n")
|
||||
}
|
||||
if childRef != "" {
|
||||
// Determine parent molecule
|
||||
parentID := idB
|
||||
if bIsProto {
|
||||
parentID = idA
|
||||
}
|
||||
resolvedRef := substituteVariables(childRef, vars)
|
||||
fmt.Printf(" Bonded ID: %s.%s\n", parentID, resolvedRef)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" Result: compound molecule\n")
|
||||
if childRef != "" {
|
||||
fmt.Printf(" Note: --ref ignored for mol+mol (use when bonding proto to molecule)\n")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -197,9 +227,9 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
||||
// Compound protos are templates - always use permanent storage
|
||||
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
|
||||
case aIsProto && !bIsProto:
|
||||
result, err = bondProtoMol(ctx, targetStore, issueA, issueB, bondType, vars, actor)
|
||||
result, err = bondProtoMol(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor)
|
||||
case !aIsProto && bIsProto:
|
||||
result, err = bondMolProto(ctx, targetStore, issueA, issueB, bondType, vars, actor)
|
||||
result, err = bondMolProto(ctx, targetStore, issueA, issueB, bondType, vars, childRef, actor)
|
||||
default:
|
||||
result, err = bondMolMol(ctx, targetStore, issueA, issueB, bondType, actor)
|
||||
}
|
||||
@@ -334,8 +364,9 @@ func bondProtoProto(ctx context.Context, s storage.Storage, protoA, protoB *type
|
||||
}, nil
|
||||
}
|
||||
|
||||
// bondProtoMol bonds a proto to an existing molecule by spawning the proto
|
||||
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, actorName string) (*BondResult, error) {
|
||||
// bondProtoMol bonds a proto to an existing molecule by spawning the proto.
|
||||
// If childRef is provided, generates custom IDs like "parent.childref" (dynamic bonding).
|
||||
func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issue, bondType string, vars map[string]string, childRef string, actorName string) (*BondResult, error) {
|
||||
// Load proto subgraph
|
||||
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
|
||||
if err != nil {
|
||||
@@ -354,8 +385,21 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
|
||||
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
|
||||
}
|
||||
|
||||
// Spawn the proto (wisp by default for molecule execution - bd-2vh3)
|
||||
spawnResult, err := spawnMolecule(ctx, s, subgraph, vars, "", actorName, true)
|
||||
// Build CloneOptions for spawning
|
||||
opts := CloneOptions{
|
||||
Vars: vars,
|
||||
Actor: actorName,
|
||||
Wisp: true, // wisp by default for molecule execution - bd-2vh3
|
||||
}
|
||||
|
||||
// Dynamic bonding: use custom IDs if childRef is provided
|
||||
if childRef != "" {
|
||||
opts.ParentID = mol.ID
|
||||
opts.ChildRef = childRef
|
||||
}
|
||||
|
||||
// Spawn the proto with options
|
||||
spawnResult, err := spawnMoleculeWithOptions(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spawning proto: %w", err)
|
||||
}
|
||||
@@ -400,9 +444,9 @@ func bondProtoMol(ctx context.Context, s storage.Storage, proto, mol *types.Issu
|
||||
}
|
||||
|
||||
// bondMolProto bonds a molecule to a proto (symmetric with bondProtoMol)
|
||||
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, actorName string) (*BondResult, error) {
|
||||
func bondMolProto(ctx context.Context, s storage.Storage, mol, proto *types.Issue, bondType string, vars map[string]string, childRef string, actorName string) (*BondResult, error) {
|
||||
// Same as bondProtoMol but with arguments swapped
|
||||
return bondProtoMol(ctx, s, proto, mol, bondType, vars, actorName)
|
||||
return bondProtoMol(ctx, s, proto, mol, bondType, vars, childRef, actorName)
|
||||
}
|
||||
|
||||
// bondMolMol bonds two molecules together
|
||||
@@ -462,6 +506,7 @@ func init() {
|
||||
molBondCmd.Flags().StringSlice("var", []string{}, "Variable substitution for spawned protos (key=value)")
|
||||
molBondCmd.Flags().Bool("wisp", false, "Force spawn as vapor (ephemeral in .beads-wisp/)")
|
||||
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent in .beads/)")
|
||||
molBondCmd.Flags().String("ref", "", "Custom child reference with {{var}} substitution (e.g., arm-{{polecat_name}})")
|
||||
|
||||
molCmd.AddCommand(molBondCmd)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func runMolSpawn(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
for _, attach := range attachments {
|
||||
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor)
|
||||
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -343,7 +343,7 @@ func TestBondProtoMol(t *testing.T) {
|
||||
|
||||
// Bond proto to molecule
|
||||
vars := map[string]string{"name": "auth-feature"}
|
||||
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "test")
|
||||
result, err := bondProtoMol(ctx, store, proto, mol, types.BondTypeSequential, vars, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("bondProtoMol failed: %v", err)
|
||||
}
|
||||
@@ -840,7 +840,7 @@ func TestSpawnWithBasicAttach(t *testing.T) {
|
||||
}
|
||||
|
||||
// Attach the second proto (simulating --attach flag behavior)
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test")
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bond attachment: %v", err)
|
||||
}
|
||||
@@ -945,12 +945,12 @@ func TestSpawnWithMultipleAttachments(t *testing.T) {
|
||||
}
|
||||
|
||||
// Attach both protos (simulating --attach A --attach B)
|
||||
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "test")
|
||||
bondResultA, err := bondProtoMol(ctx, s, attachA, spawnedMol, types.BondTypeSequential, nil, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bond attachA: %v", err)
|
||||
}
|
||||
|
||||
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "test")
|
||||
bondResultB, err := bondProtoMol(ctx, s, attachB, spawnedMol, types.BondTypeSequential, nil, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bond attachB: %v", err)
|
||||
}
|
||||
@@ -1063,7 +1063,7 @@ func TestSpawnAttachTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
// Bond with specified type
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "test")
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, tt.bondType, nil, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bond: %v", err)
|
||||
}
|
||||
@@ -1228,7 +1228,7 @@ func TestSpawnVariableAggregation(t *testing.T) {
|
||||
|
||||
// Bond attachment with same variables
|
||||
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "test")
|
||||
bondResult, err := bondProtoMol(ctx, s, attachProto, spawnedMol, types.BondTypeSequential, vars, "", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to bond: %v", err)
|
||||
}
|
||||
@@ -2024,3 +2024,324 @@ func TestAdvanceToNextStepOrphanIssue(t *testing.T) {
|
||||
t.Error("result should be nil for orphan issue")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Dynamic Bonding Tests (bd-xo1o.1)
|
||||
// =============================================================================
|
||||
|
||||
// TestGenerateBondedID tests the custom ID generation for dynamic bonding
|
||||
func TestGenerateBondedID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oldID string
|
||||
rootID string
|
||||
opts CloneOptions
|
||||
wantID string
|
||||
wantErr bool
|
||||
errMatch string
|
||||
}{
|
||||
{
|
||||
name: "root issue with simple childRef",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "arm-ace",
|
||||
},
|
||||
wantID: "patrol-x7k.arm-ace",
|
||||
},
|
||||
{
|
||||
name: "root issue with variable substitution",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "arm-{{polecat_name}}",
|
||||
Vars: map[string]string{"polecat_name": "ace"},
|
||||
},
|
||||
wantID: "patrol-x7k.arm-ace",
|
||||
},
|
||||
{
|
||||
name: "child issue with relative ID",
|
||||
oldID: "mol-arm.capture",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "arm-ace",
|
||||
},
|
||||
wantID: "patrol-x7k.arm-ace.capture",
|
||||
},
|
||||
{
|
||||
name: "nested child issue",
|
||||
oldID: "mol-arm.capture.sub",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "arm-ace",
|
||||
},
|
||||
wantID: "patrol-x7k.arm-ace.capture.sub",
|
||||
},
|
||||
{
|
||||
name: "no parent ID returns empty (not a bonded operation)",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{},
|
||||
wantID: "",
|
||||
},
|
||||
{
|
||||
name: "empty childRef after substitution is error",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "{{missing_var}}",
|
||||
},
|
||||
wantErr: true,
|
||||
errMatch: "invalid childRef",
|
||||
},
|
||||
{
|
||||
name: "childRef with special chars is error",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
opts: CloneOptions{
|
||||
ParentID: "patrol-x7k",
|
||||
ChildRef: "arm/ace",
|
||||
},
|
||||
wantErr: true,
|
||||
errMatch: "invalid childRef",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotID, err := generateBondedID(tt.oldID, tt.rootID, tt.opts)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("generateBondedID() expected error containing %q, got nil", tt.errMatch)
|
||||
} else if !strings.Contains(err.Error(), tt.errMatch) {
|
||||
t.Errorf("generateBondedID() error = %q, want error containing %q", err.Error(), tt.errMatch)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("generateBondedID() unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if gotID != tt.wantID {
|
||||
t.Errorf("generateBondedID() = %q, want %q", gotID, tt.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetRelativeID tests extracting relative portion from child IDs
|
||||
func TestGetRelativeID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oldID string
|
||||
rootID string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "same ID returns empty",
|
||||
oldID: "mol-arm",
|
||||
rootID: "mol-arm",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "child with single step",
|
||||
oldID: "mol-arm.capture",
|
||||
rootID: "mol-arm",
|
||||
want: "capture",
|
||||
},
|
||||
{
|
||||
name: "child with nested steps",
|
||||
oldID: "mol-arm.capture.sub.deep",
|
||||
rootID: "mol-arm",
|
||||
want: "capture.sub.deep",
|
||||
},
|
||||
{
|
||||
name: "unrelated IDs returns empty",
|
||||
oldID: "other-123",
|
||||
rootID: "mol-arm",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := getRelativeID(tt.oldID, tt.rootID)
|
||||
if got != tt.want {
|
||||
t.Errorf("getRelativeID() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBondProtoMolWithRef tests dynamic bonding with custom child references
|
||||
func TestBondProtoMolWithRef(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", "patrol"); err != nil {
|
||||
t.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
// Create a proto with child steps (mol-polecat-arm template)
|
||||
protoRoot := &types.Issue{
|
||||
Title: "Polecat Arm: {{polecat_name}}",
|
||||
IssueType: types.TypeEpic,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
Labels: []string{MoleculeLabel},
|
||||
}
|
||||
if err := s.CreateIssue(ctx, protoRoot, "test"); err != nil {
|
||||
t.Fatalf("Failed to create proto root: %v", err)
|
||||
}
|
||||
|
||||
// Add proto steps
|
||||
protoCapture := &types.Issue{
|
||||
Title: "Capture {{polecat_name}}",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
}
|
||||
if err := s.CreateIssue(ctx, protoCapture, "test"); err != nil {
|
||||
t.Fatalf("Failed to create proto capture: %v", err)
|
||||
}
|
||||
if err := s.AddDependency(ctx, &types.Dependency{
|
||||
IssueID: protoCapture.ID,
|
||||
DependsOnID: protoRoot.ID,
|
||||
Type: types.DepParentChild,
|
||||
}, "test"); err != nil {
|
||||
t.Fatalf("Failed to add proto dependency: %v", err)
|
||||
}
|
||||
|
||||
// Create target molecule (patrol-xxx)
|
||||
patrol := &types.Issue{
|
||||
Title: "Witness Patrol",
|
||||
IssueType: types.TypeEpic,
|
||||
Status: types.StatusInProgress,
|
||||
Priority: 1,
|
||||
}
|
||||
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
|
||||
t.Fatalf("Failed to create patrol: %v", err)
|
||||
}
|
||||
|
||||
// Bond proto to patrol with custom child ref
|
||||
vars := map[string]string{"polecat_name": "ace"}
|
||||
childRef := "arm-{{polecat_name}}"
|
||||
result, err := bondProtoMol(ctx, s, protoRoot, patrol, types.BondTypeSequential, vars, childRef, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("bondProtoMol failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify spawned count
|
||||
if result.Spawned != 2 {
|
||||
t.Errorf("Spawned = %d, want 2", result.Spawned)
|
||||
}
|
||||
|
||||
// Verify root ID follows pattern: patrol.arm-ace
|
||||
expectedRootID := patrol.ID + ".arm-ace"
|
||||
if result.IDMapping[protoRoot.ID] != expectedRootID {
|
||||
t.Errorf("Root ID = %q, want %q", result.IDMapping[protoRoot.ID], expectedRootID)
|
||||
}
|
||||
|
||||
// Verify child ID follows pattern: patrol.arm-ace.relative
|
||||
// The child's ID should be patrol.arm-ace.capture (but relative part depends on proto structure)
|
||||
childID := result.IDMapping[protoCapture.ID]
|
||||
if !strings.HasPrefix(childID, expectedRootID+".") {
|
||||
t.Errorf("Child ID %q should start with %q", childID, expectedRootID+".")
|
||||
}
|
||||
|
||||
// Verify the spawned issues exist and have correct titles
|
||||
spawnedRoot, err := s.GetIssue(ctx, expectedRootID)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get spawned root: %v", err)
|
||||
}
|
||||
if !strings.Contains(spawnedRoot.Title, "ace") {
|
||||
t.Errorf("Spawned root title %q should contain 'ace'", spawnedRoot.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBondProtoMolMultipleArms tests bonding multiple arms to the same parent
|
||||
func TestBondProtoMolMultipleArms(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", "patrol"); err != nil {
|
||||
t.Fatalf("Failed to set config: %v", err)
|
||||
}
|
||||
|
||||
// Create simple proto
|
||||
proto := &types.Issue{
|
||||
Title: "Arm: {{name}}",
|
||||
IssueType: types.TypeTask,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
Labels: []string{MoleculeLabel},
|
||||
}
|
||||
if err := s.CreateIssue(ctx, proto, "test"); err != nil {
|
||||
t.Fatalf("Failed to create proto: %v", err)
|
||||
}
|
||||
|
||||
// Create parent patrol
|
||||
patrol := &types.Issue{
|
||||
Title: "Patrol",
|
||||
IssueType: types.TypeEpic,
|
||||
Status: types.StatusOpen,
|
||||
Priority: 1,
|
||||
}
|
||||
if err := s.CreateIssue(ctx, patrol, "test"); err != nil {
|
||||
t.Fatalf("Failed to create patrol: %v", err)
|
||||
}
|
||||
|
||||
// Bond arm-ace
|
||||
varsAce := map[string]string{"name": "ace"}
|
||||
resultAce, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsAce, "arm-{{name}}", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("bondProtoMol (ace) failed: %v", err)
|
||||
}
|
||||
|
||||
// Bond arm-nux
|
||||
varsNux := map[string]string{"name": "nux"}
|
||||
resultNux, err := bondProtoMol(ctx, s, proto, patrol, types.BondTypeParallel, varsNux, "arm-{{name}}", "test")
|
||||
if err != nil {
|
||||
t.Fatalf("bondProtoMol (nux) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify IDs are correct and distinct
|
||||
aceID := resultAce.IDMapping[proto.ID]
|
||||
nuxID := resultNux.IDMapping[proto.ID]
|
||||
|
||||
expectedAceID := patrol.ID + ".arm-ace"
|
||||
expectedNuxID := patrol.ID + ".arm-nux"
|
||||
|
||||
if aceID != expectedAceID {
|
||||
t.Errorf("Ace ID = %q, want %q", aceID, expectedAceID)
|
||||
}
|
||||
if nuxID != expectedNuxID {
|
||||
t.Errorf("Nux ID = %q, want %q", nuxID, expectedNuxID)
|
||||
}
|
||||
|
||||
// Verify both exist
|
||||
aceIssue, err := s.GetIssue(ctx, aceID)
|
||||
if err != nil || aceIssue == nil {
|
||||
t.Errorf("Ace issue not found: %v", err)
|
||||
}
|
||||
nuxIssue, err := s.GetIssue(ctx, nuxID)
|
||||
if err != nil || nuxIssue == nil {
|
||||
t.Errorf("Nux issue not found: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func runPour(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
for _, attach := range attachments {
|
||||
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, actor)
|
||||
bondResult, err := bondProtoMol(ctx, store, attach.issue, spawnedMol, attachType, vars, "", actor)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -38,6 +38,21 @@ type InstantiateResult struct {
|
||||
Created int `json:"created"` // number of issues created
|
||||
}
|
||||
|
||||
// CloneOptions controls how the subgraph is cloned during spawn/bond
|
||||
type CloneOptions struct {
|
||||
Vars map[string]string // Variable substitutions for {{key}} placeholders
|
||||
Assignee string // Assign the root epic to this agent/user
|
||||
Actor string // Actor performing the operation
|
||||
Wisp bool // If true, spawned issues are marked for bulk deletion
|
||||
|
||||
// Dynamic bonding fields (for Christmas Ornament pattern)
|
||||
ParentID string // Parent molecule ID to bond under (e.g., "patrol-x7k")
|
||||
ChildRef string // Child reference with variables (e.g., "arm-{{polecat_name}}")
|
||||
}
|
||||
|
||||
// bondedIDPattern validates bonded IDs (alphanumeric, dash, underscore, dot)
|
||||
var bondedIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`)
|
||||
|
||||
var templateCmd = &cobra.Command{
|
||||
Use: "template",
|
||||
GroupID: "setup",
|
||||
@@ -293,7 +308,13 @@ Example:
|
||||
}
|
||||
|
||||
// Clone the subgraph (deprecated command, non-wisp for backwards compatibility)
|
||||
result, err := cloneSubgraph(ctx, store, subgraph, vars, assignee, actor, false)
|
||||
opts := CloneOptions{
|
||||
Vars: vars,
|
||||
Assignee: assignee,
|
||||
Actor: actor,
|
||||
Wisp: false,
|
||||
}
|
||||
result, err := cloneSubgraph(ctx, store, subgraph, opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -451,10 +472,85 @@ func substituteVariables(text string, vars map[string]string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// cloneSubgraph creates new issues from the template with variable substitution
|
||||
// If assignee is non-empty, it will be set on the root epic
|
||||
// If wisp is true, spawned issues are marked for bulk deletion when closed (bd-2vh3)
|
||||
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, vars map[string]string, assignee string, actorName string, wisp bool) (*InstantiateResult, error) {
|
||||
// generateBondedID creates a custom ID for dynamically bonded molecules.
|
||||
// When bonding a proto to a parent molecule, this generates IDs like:
|
||||
// - Root: parent.childref (e.g., "patrol-x7k.arm-ace")
|
||||
// - Children: parent.childref.step (e.g., "patrol-x7k.arm-ace.capture")
|
||||
//
|
||||
// The childRef is variable-substituted before use.
|
||||
// Returns empty string if not a bonded operation (opts.ParentID empty).
|
||||
func generateBondedID(oldID string, rootID string, opts CloneOptions) (string, error) {
|
||||
if opts.ParentID == "" {
|
||||
return "", nil // Not a bonded operation
|
||||
}
|
||||
|
||||
// Substitute variables in childRef
|
||||
childRef := substituteVariables(opts.ChildRef, opts.Vars)
|
||||
|
||||
// Validate childRef after substitution
|
||||
if childRef == "" {
|
||||
return "", fmt.Errorf("childRef is empty after variable substitution")
|
||||
}
|
||||
if !bondedIDPattern.MatchString(childRef) {
|
||||
return "", fmt.Errorf("invalid childRef '%s': must be alphanumeric, dash, underscore, or dot only", childRef)
|
||||
}
|
||||
|
||||
if oldID == rootID {
|
||||
// Root issue: parent.childref
|
||||
newID := fmt.Sprintf("%s.%s", opts.ParentID, childRef)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// Child issue: parent.childref.relative
|
||||
// Extract the relative portion of the old ID (part after root)
|
||||
relativeID := getRelativeID(oldID, rootID)
|
||||
if relativeID == "" {
|
||||
// No hierarchical relationship - use a suffix from the old ID to ensure uniqueness.
|
||||
// Extract the last part of the old ID (after any prefix or dash)
|
||||
suffix := extractIDSuffix(oldID)
|
||||
newID := fmt.Sprintf("%s.%s.%s", opts.ParentID, childRef, suffix)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
newID := fmt.Sprintf("%s.%s.%s", opts.ParentID, childRef, relativeID)
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
// extractIDSuffix extracts a suffix from an ID for use when IDs aren't hierarchical.
|
||||
// For "patrol-abc123", returns "abc123".
|
||||
// For "bd-xyz.1", returns "1".
|
||||
// This ensures child IDs remain unique when bonding.
|
||||
func extractIDSuffix(id string) string {
|
||||
// First try to get the part after the last dot (for hierarchical IDs)
|
||||
if lastDot := strings.LastIndex(id, "."); lastDot >= 0 {
|
||||
return id[lastDot+1:]
|
||||
}
|
||||
// Otherwise, get the part after the last dash (for prefix-hash IDs)
|
||||
if lastDash := strings.LastIndex(id, "-"); lastDash >= 0 {
|
||||
return id[lastDash+1:]
|
||||
}
|
||||
// Fallback: use the whole ID
|
||||
return id
|
||||
}
|
||||
|
||||
// getRelativeID extracts the relative portion of a child ID from its parent.
|
||||
// For example: getRelativeID("bd-abc.step1.sub", "bd-abc") returns "step1.sub"
|
||||
// Returns empty string if oldID equals rootID or doesn't start with rootID.
|
||||
func getRelativeID(oldID, rootID string) string {
|
||||
if oldID == rootID {
|
||||
return ""
|
||||
}
|
||||
// Check if oldID starts with rootID followed by a dot
|
||||
prefix := rootID + "."
|
||||
if strings.HasPrefix(oldID, prefix) {
|
||||
return oldID[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cloneSubgraph creates new issues from the template with variable substitution.
|
||||
// Uses CloneOptions to control all spawn/bond behavior including dynamic bonding.
|
||||
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, opts CloneOptions) (*InstantiateResult, error) {
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("no database connection")
|
||||
}
|
||||
@@ -468,28 +564,37 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
|
||||
for _, oldIssue := range subgraph.Issues {
|
||||
// Determine assignee: use override for root epic, otherwise keep template's
|
||||
issueAssignee := oldIssue.Assignee
|
||||
if oldIssue.ID == subgraph.Root.ID && assignee != "" {
|
||||
issueAssignee = assignee
|
||||
if oldIssue.ID == subgraph.Root.ID && opts.Assignee != "" {
|
||||
issueAssignee = opts.Assignee
|
||||
}
|
||||
|
||||
newIssue := &types.Issue{
|
||||
// Don't set ID - let the system generate it
|
||||
Title: substituteVariables(oldIssue.Title, vars),
|
||||
Description: substituteVariables(oldIssue.Description, vars),
|
||||
Design: substituteVariables(oldIssue.Design, vars),
|
||||
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, vars),
|
||||
Notes: substituteVariables(oldIssue.Notes, vars),
|
||||
// ID will be set below based on bonding options
|
||||
Title: substituteVariables(oldIssue.Title, opts.Vars),
|
||||
Description: substituteVariables(oldIssue.Description, opts.Vars),
|
||||
Design: substituteVariables(oldIssue.Design, opts.Vars),
|
||||
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars),
|
||||
Notes: substituteVariables(oldIssue.Notes, opts.Vars),
|
||||
Status: types.StatusOpen, // Always start fresh
|
||||
Priority: oldIssue.Priority,
|
||||
IssueType: oldIssue.IssueType,
|
||||
Assignee: issueAssignee,
|
||||
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
||||
Wisp: wisp, // bd-2vh3: mark for cleanup when closed
|
||||
Wisp: opts.Wisp, // bd-2vh3: mark for cleanup when closed
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.CreateIssue(ctx, newIssue, actorName); err != nil {
|
||||
// Generate custom ID for dynamic bonding if ParentID is set
|
||||
if opts.ParentID != "" {
|
||||
bondedID, err := generateBondedID(oldIssue.ID, subgraph.Root.ID, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate bonded ID for %s: %w", oldIssue.ID, err)
|
||||
}
|
||||
newIssue.ID = bondedID
|
||||
}
|
||||
|
||||
if err := tx.CreateIssue(ctx, newIssue, opts.Actor); err != nil {
|
||||
return fmt.Errorf("failed to create issue from %s: %w", oldIssue.ID, err)
|
||||
}
|
||||
|
||||
@@ -509,7 +614,7 @@ func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSub
|
||||
DependsOnID: newToID,
|
||||
Type: dep.Type,
|
||||
}
|
||||
if err := tx.AddDependency(ctx, newDep, actorName); err != nil {
|
||||
if err := tx.AddDependency(ctx, newDep, opts.Actor); err != nil {
|
||||
return fmt.Errorf("failed to create dependency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,8 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
}
|
||||
|
||||
vars := map[string]string{"version": "2.0.0"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user", false)
|
||||
opts := CloneOptions{Vars: vars, Actor: "test-user"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -308,7 +309,8 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
}
|
||||
|
||||
vars := map[string]string{"service": "api-gateway"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, vars, "", "test-user", false)
|
||||
opts := CloneOptions{Vars: vars, Actor: "test-user"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -367,7 +369,8 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, nil, "", "test-user", false)
|
||||
opts := CloneOptions{Actor: "test-user"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
@@ -402,7 +405,8 @@ func TestCloneSubgraph(t *testing.T) {
|
||||
}
|
||||
|
||||
// Clone with assignee override
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, nil, "new-assignee", "test-user", false)
|
||||
opts := CloneOptions{Assignee: "new-assignee", Actor: "test-user"}
|
||||
result, err := cloneSubgraph(ctx, s, subgraph, opts)
|
||||
if err != nil {
|
||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user