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.
|
// Wraps cloneSubgraph from template.go and returns InstantiateResult.
|
||||||
// If ephemeral is true, spawned issues are marked for bulk deletion when closed.
|
// 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) {
|
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
|
// 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
|
--pour Force spawn as liquid (persistent), even when attaching to wisp
|
||||||
--wisp Force spawn as vapor (ephemeral), even when attaching to mol
|
--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:
|
Use cases:
|
||||||
- Found important bug during patrol? Use --pour to persist it
|
- Found important bug during patrol? Use --pour to persist it
|
||||||
- Need ephemeral diagnostic on persistent feature? Use --wisp
|
- Need ephemeral diagnostic on persistent feature? Use --wisp
|
||||||
|
- Spawning per-worker arms on a patrol? Use --ref for readable IDs
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd mol bond mol-feature mol-deploy # Compound proto
|
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 mol-feature bd-abc123 # Attach proto to molecule
|
||||||
bd mol bond bd-abc123 bd-def456 # Join two molecules
|
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-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),
|
Args: cobra.ExactArgs(2),
|
||||||
Run: runMolBond,
|
Run: runMolBond,
|
||||||
}
|
}
|
||||||
@@ -88,6 +98,7 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
|||||||
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
||||||
wisp, _ := cmd.Flags().GetBool("wisp")
|
wisp, _ := cmd.Flags().GetBool("wisp")
|
||||||
pour, _ := cmd.Flags().GetBool("pour")
|
pour, _ := cmd.Flags().GetBool("pour")
|
||||||
|
childRef, _ := cmd.Flags().GetString("ref")
|
||||||
|
|
||||||
// Validate phase flags are not both set
|
// Validate phase flags are not both set
|
||||||
if wisp && pour {
|
if wisp && pour {
|
||||||
@@ -170,6 +181,10 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
|||||||
} else if pour {
|
} else if pour {
|
||||||
fmt.Printf(" Phase override: liquid (--pour)\n")
|
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 {
|
if aIsProto && bIsProto {
|
||||||
fmt.Printf(" Result: compound proto\n")
|
fmt.Printf(" Result: compound proto\n")
|
||||||
if customTitle != "" {
|
if customTitle != "" {
|
||||||
@@ -178,13 +193,28 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
|||||||
if wisp || pour {
|
if wisp || pour {
|
||||||
fmt.Printf(" Note: phase flags ignored for proto+proto (templates stay in permanent storage)\n")
|
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 {
|
} else if aIsProto || bIsProto {
|
||||||
fmt.Printf(" Result: spawn proto, attach to molecule\n")
|
fmt.Printf(" Result: spawn proto, attach to molecule\n")
|
||||||
if !wisp && !pour {
|
if !wisp && !pour {
|
||||||
fmt.Printf(" Phase: follows target's phase\n")
|
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 {
|
} else {
|
||||||
fmt.Printf(" Result: compound molecule\n")
|
fmt.Printf(" Result: compound molecule\n")
|
||||||
|
if childRef != "" {
|
||||||
|
fmt.Printf(" Note: --ref ignored for mol+mol (use when bonding proto to molecule)\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -197,9 +227,9 @@ func runMolBond(cmd *cobra.Command, args []string) {
|
|||||||
// Compound protos are templates - always use permanent storage
|
// Compound protos are templates - always use permanent storage
|
||||||
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
|
result, err = bondProtoProto(ctx, store, issueA, issueB, bondType, customTitle, actor)
|
||||||
case aIsProto && !bIsProto:
|
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:
|
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:
|
default:
|
||||||
result, err = bondMolMol(ctx, targetStore, issueA, issueB, bondType, actor)
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// bondProtoMol bonds a proto to an existing molecule by spawning the proto
|
// 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) {
|
// 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
|
// Load proto subgraph
|
||||||
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
|
subgraph, err := loadTemplateSubgraph(ctx, s, proto.ID)
|
||||||
if err != nil {
|
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, ", "))
|
return nil, fmt.Errorf("missing required variables: %s (use --var)", strings.Join(missingVars, ", "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn the proto (wisp by default for molecule execution - bd-2vh3)
|
// Build CloneOptions for spawning
|
||||||
spawnResult, err := spawnMolecule(ctx, s, subgraph, vars, "", actorName, true)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("spawning proto: %w", err)
|
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)
|
// 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
|
// 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
|
// 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().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("wisp", false, "Force spawn as vapor (ephemeral in .beads-wisp/)")
|
||||||
molBondCmd.Flags().Bool("pour", false, "Force spawn as liquid (persistent in .beads/)")
|
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)
|
molCmd.AddCommand(molBondCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ func runMolSpawn(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, attach := range attachments {
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ func TestBondProtoMol(t *testing.T) {
|
|||||||
|
|
||||||
// Bond proto to molecule
|
// Bond proto to molecule
|
||||||
vars := map[string]string{"name": "auth-feature"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("bondProtoMol failed: %v", err)
|
t.Fatalf("bondProtoMol failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -840,7 +840,7 @@ func TestSpawnWithBasicAttach(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Attach the second proto (simulating --attach flag behavior)
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to bond attachment: %v", err)
|
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)
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to bond attachA: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to bond attachB: %v", err)
|
t.Fatalf("Failed to bond attachB: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1063,7 +1063,7 @@ func TestSpawnAttachTypes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Bond with specified type
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to bond: %v", err)
|
t.Fatalf("Failed to bond: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1228,7 +1228,7 @@ func TestSpawnVariableAggregation(t *testing.T) {
|
|||||||
|
|
||||||
// Bond attachment with same variables
|
// Bond attachment with same variables
|
||||||
spawnedMol, _ := s.GetIssue(ctx, spawnResult.NewEpicID)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to bond: %v", err)
|
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")
|
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 {
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
fmt.Fprintf(os.Stderr, "Error attaching %s: %v\n", attach.id, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ type InstantiateResult struct {
|
|||||||
Created int `json:"created"` // number of issues created
|
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{
|
var templateCmd = &cobra.Command{
|
||||||
Use: "template",
|
Use: "template",
|
||||||
GroupID: "setup",
|
GroupID: "setup",
|
||||||
@@ -293,7 +308,13 @@ Example:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clone the subgraph (deprecated command, non-wisp for backwards compatibility)
|
// 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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
||||||
os.Exit(1)
|
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
|
// generateBondedID creates a custom ID for dynamically bonded molecules.
|
||||||
// If assignee is non-empty, it will be set on the root epic
|
// When bonding a proto to a parent molecule, this generates IDs like:
|
||||||
// If wisp is true, spawned issues are marked for bulk deletion when closed (bd-2vh3)
|
// - Root: parent.childref (e.g., "patrol-x7k.arm-ace")
|
||||||
func cloneSubgraph(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, vars map[string]string, assignee string, actorName string, wisp bool) (*InstantiateResult, error) {
|
// - 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 {
|
if s == nil {
|
||||||
return nil, fmt.Errorf("no database connection")
|
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 {
|
for _, oldIssue := range subgraph.Issues {
|
||||||
// Determine assignee: use override for root epic, otherwise keep template's
|
// Determine assignee: use override for root epic, otherwise keep template's
|
||||||
issueAssignee := oldIssue.Assignee
|
issueAssignee := oldIssue.Assignee
|
||||||
if oldIssue.ID == subgraph.Root.ID && assignee != "" {
|
if oldIssue.ID == subgraph.Root.ID && opts.Assignee != "" {
|
||||||
issueAssignee = assignee
|
issueAssignee = opts.Assignee
|
||||||
}
|
}
|
||||||
|
|
||||||
newIssue := &types.Issue{
|
newIssue := &types.Issue{
|
||||||
// Don't set ID - let the system generate it
|
// ID will be set below based on bonding options
|
||||||
Title: substituteVariables(oldIssue.Title, vars),
|
Title: substituteVariables(oldIssue.Title, opts.Vars),
|
||||||
Description: substituteVariables(oldIssue.Description, vars),
|
Description: substituteVariables(oldIssue.Description, opts.Vars),
|
||||||
Design: substituteVariables(oldIssue.Design, vars),
|
Design: substituteVariables(oldIssue.Design, opts.Vars),
|
||||||
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, vars),
|
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars),
|
||||||
Notes: substituteVariables(oldIssue.Notes, vars),
|
Notes: substituteVariables(oldIssue.Notes, opts.Vars),
|
||||||
Status: types.StatusOpen, // Always start fresh
|
Status: types.StatusOpen, // Always start fresh
|
||||||
Priority: oldIssue.Priority,
|
Priority: oldIssue.Priority,
|
||||||
IssueType: oldIssue.IssueType,
|
IssueType: oldIssue.IssueType,
|
||||||
Assignee: issueAssignee,
|
Assignee: issueAssignee,
|
||||||
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
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(),
|
CreatedAt: time.Now(),
|
||||||
UpdatedAt: 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)
|
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,
|
DependsOnID: newToID,
|
||||||
Type: dep.Type,
|
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)
|
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"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -308,7 +309,8 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vars := map[string]string{"service": "api-gateway"}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -367,7 +369,8 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
t.Fatalf("loadTemplateSubgraph failed: %v", err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -402,7 +405,8 @@ func TestCloneSubgraph(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clone with assignee override
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("cloneSubgraph failed: %v", err)
|
t.Fatalf("cloneSubgraph failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user