Added type-specific ID prefixes for better visual recognition: - `bd pour` now generates IDs with "mol-" prefix - `bd wisp create` now generates IDs with "wisp-" prefix - Regular issues continue using the configured prefix Implementation: - Added IDPrefix field to types.Issue (internal, not exported to JSONL) - Added Prefix field to CloneOptions for spawning operations - Added IDPrefix to RPC CreateArgs for daemon communication - Updated storage layer to use issue.IDPrefix when generating IDs - Updated pour.go and wisp.go to pass appropriate prefixes This enables instant visual recognition of entity types and prevents accidental modification of templates. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
998 lines
32 KiB
Go
998 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
// BeadsTemplateLabel is the label used to identify Beads-based templates
|
|
const BeadsTemplateLabel = "template"
|
|
|
|
// variablePattern matches {{variable}} placeholders
|
|
var variablePattern = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`)
|
|
|
|
// TemplateSubgraph holds a template epic and all its descendants
|
|
type TemplateSubgraph struct {
|
|
Root *types.Issue // The template epic
|
|
Issues []*types.Issue // All issues in the subgraph (including root)
|
|
Dependencies []*types.Dependency // All dependencies within the subgraph
|
|
IssueMap map[string]*types.Issue // ID -> Issue for quick lookup
|
|
}
|
|
|
|
// InstantiateResult holds the result of template instantiation
|
|
type InstantiateResult struct {
|
|
NewEpicID string `json:"new_epic_id"`
|
|
IDMapping map[string]string `json:"id_mapping"` // old ID -> new ID
|
|
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
|
|
Prefix string // Override prefix for ID generation (bd-hobo: distinct prefixes)
|
|
|
|
// 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",
|
|
Short: "Manage issue templates",
|
|
Deprecated: "use 'bd mol' instead (mol catalog, mol show, mol bond)",
|
|
Long: `Manage Beads templates for creating issue hierarchies.
|
|
|
|
Templates are epics with the "template" label. They can have child issues
|
|
with {{variable}} placeholders that get substituted during instantiation.
|
|
|
|
To create a template:
|
|
1. Create an epic with child issues
|
|
2. Add the 'template' label: bd label add <epic-id> template
|
|
3. Use {{variable}} placeholders in titles/descriptions
|
|
|
|
To use a template:
|
|
bd template instantiate <id> --var key=value`,
|
|
}
|
|
|
|
var templateListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List available templates",
|
|
Deprecated: "use 'bd mol catalog' instead",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
var beadsTemplates []*types.Issue
|
|
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.List(&rpc.ListArgs{})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading templates: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
var allIssues []*types.Issue
|
|
if err := json.Unmarshal(resp.Data, &allIssues); err == nil {
|
|
for _, issue := range allIssues {
|
|
for _, label := range issue.Labels {
|
|
if label == BeadsTemplateLabel {
|
|
beadsTemplates = append(beadsTemplates, issue)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if store != nil {
|
|
var err error
|
|
beadsTemplates, err = store.GetIssuesByLabel(ctx, BeadsTemplateLabel)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading templates: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(beadsTemplates)
|
|
return
|
|
}
|
|
|
|
// Human-readable output
|
|
if len(beadsTemplates) == 0 {
|
|
fmt.Println("No templates available.")
|
|
fmt.Println("\nTo create a template:")
|
|
fmt.Println(" 1. Create an epic with child issues")
|
|
fmt.Println(" 2. Add the 'template' label: bd label add <epic-id> template")
|
|
fmt.Println(" 3. Use {{variable}} placeholders in titles/descriptions")
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s\n", ui.RenderPass("Templates (for bd template instantiate):"))
|
|
for _, tmpl := range beadsTemplates {
|
|
vars := extractVariables(tmpl.Title + " " + tmpl.Description)
|
|
varStr := ""
|
|
if len(vars) > 0 {
|
|
varStr = fmt.Sprintf(" (vars: %s)", strings.Join(vars, ", "))
|
|
}
|
|
fmt.Printf(" %s: %s%s\n", ui.RenderAccent(tmpl.ID), tmpl.Title, varStr)
|
|
}
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
var templateShowCmd = &cobra.Command{
|
|
Use: "show <template-id>",
|
|
Short: "Show template details",
|
|
Deprecated: "use 'bd mol show' instead",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
var templateID string
|
|
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: template '%s' not found\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &templateID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else if store != nil {
|
|
var err error
|
|
templateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: template '%s' not found\n", args[0])
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Load and show Beads template
|
|
var subgraph *TemplateSubgraph
|
|
var err error
|
|
if daemonClient != nil {
|
|
subgraph, err = loadTemplateSubgraphViaDaemon(daemonClient, templateID)
|
|
} else {
|
|
subgraph, err = loadTemplateSubgraph(ctx, store, templateID)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading template: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
showBeadsTemplate(subgraph)
|
|
},
|
|
}
|
|
|
|
func showBeadsTemplate(subgraph *TemplateSubgraph) {
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"root": subgraph.Root,
|
|
"issues": subgraph.Issues,
|
|
"dependencies": subgraph.Dependencies,
|
|
"variables": extractAllVariables(subgraph),
|
|
})
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n%s Template: %s\n", ui.RenderAccent("📋"), subgraph.Root.Title)
|
|
fmt.Printf(" ID: %s\n", subgraph.Root.ID)
|
|
fmt.Printf(" Issues: %d\n", len(subgraph.Issues))
|
|
|
|
// Show variables
|
|
vars := extractAllVariables(subgraph)
|
|
if len(vars) > 0 {
|
|
fmt.Printf("\n%s Variables:\n", ui.RenderWarn("📝"))
|
|
for _, v := range vars {
|
|
fmt.Printf(" {{%s}}\n", v)
|
|
}
|
|
}
|
|
|
|
// Show structure
|
|
fmt.Printf("\n%s Structure:\n", ui.RenderPass("🌲"))
|
|
printTemplateTree(subgraph, subgraph.Root.ID, 0, true)
|
|
fmt.Println()
|
|
}
|
|
|
|
var templateInstantiateCmd = &cobra.Command{
|
|
Use: "instantiate <template-id>",
|
|
Short: "Create issues from a Beads template",
|
|
Deprecated: "use 'bd mol bond' instead",
|
|
Long: `Instantiate a Beads template by cloning its subgraph and substituting variables.
|
|
|
|
Variables are specified with --var key=value flags. The template's {{key}}
|
|
placeholders will be replaced with the corresponding values.
|
|
|
|
Example:
|
|
bd template instantiate bd-abc123 --var version=1.2.0 --var date=2024-01-15`,
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("template instantiate")
|
|
|
|
ctx := rootCtx
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
varFlags, _ := cmd.Flags().GetStringSlice("var")
|
|
assignee, _ := cmd.Flags().GetString("assignee")
|
|
|
|
// Parse variables
|
|
vars := make(map[string]string)
|
|
for _, v := range varFlags {
|
|
parts := strings.SplitN(v, "=", 2)
|
|
if len(parts) != 2 {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid variable format '%s', expected 'key=value'\n", v)
|
|
os.Exit(1)
|
|
}
|
|
vars[parts[0]] = parts[1]
|
|
}
|
|
|
|
// Resolve template ID
|
|
var templateID string
|
|
if daemonClient != nil {
|
|
resolveArgs := &rpc.ResolveIDArgs{ID: args[0]}
|
|
resp, err := daemonClient.ResolveID(resolveArgs)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving template ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &templateID); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else if store != nil {
|
|
var err error
|
|
templateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error resolving template ID %s: %v\n", args[0], err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Load the template subgraph
|
|
var subgraph *TemplateSubgraph
|
|
var err error
|
|
if daemonClient != nil {
|
|
subgraph, err = loadTemplateSubgraphViaDaemon(daemonClient, templateID)
|
|
} else {
|
|
subgraph, err = loadTemplateSubgraph(ctx, store, templateID)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error loading template: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Check for missing variables
|
|
requiredVars := extractAllVariables(subgraph)
|
|
var missingVars []string
|
|
for _, v := range requiredVars {
|
|
if _, ok := vars[v]; !ok {
|
|
missingVars = append(missingVars, v)
|
|
}
|
|
}
|
|
if len(missingVars) > 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: missing required variables: %s\n", strings.Join(missingVars, ", "))
|
|
fmt.Fprintf(os.Stderr, "Provide them with: --var %s=<value>\n", missingVars[0])
|
|
os.Exit(1)
|
|
}
|
|
|
|
if dryRun {
|
|
// Preview what would be created
|
|
fmt.Printf("\nDry run: would create %d issues from template %s\n\n", len(subgraph.Issues), templateID)
|
|
for _, issue := range subgraph.Issues {
|
|
newTitle := substituteVariables(issue.Title, vars)
|
|
suffix := ""
|
|
if issue.ID == subgraph.Root.ID && assignee != "" {
|
|
suffix = fmt.Sprintf(" (assignee: %s)", assignee)
|
|
}
|
|
fmt.Printf(" - %s (from %s)%s\n", newTitle, issue.ID, suffix)
|
|
}
|
|
if len(vars) > 0 {
|
|
fmt.Printf("\nVariables:\n")
|
|
for k, v := range vars {
|
|
fmt.Printf(" {{%s}} = %s\n", k, v)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Clone the subgraph (deprecated command, non-wisp for backwards compatibility)
|
|
opts := CloneOptions{
|
|
Vars: vars,
|
|
Assignee: assignee,
|
|
Actor: actor,
|
|
Wisp: false,
|
|
}
|
|
var result *InstantiateResult
|
|
if daemonClient != nil {
|
|
result, err = cloneSubgraphViaDaemon(daemonClient, subgraph, opts)
|
|
} else {
|
|
result, err = cloneSubgraph(ctx, store, subgraph, opts)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error instantiating template: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Schedule auto-flush
|
|
markDirtyAndScheduleFlush()
|
|
|
|
if jsonOutput {
|
|
outputJSON(result)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s Created %d issues from template\n", ui.RenderPass("✓"), result.Created)
|
|
fmt.Printf(" New epic: %s\n", result.NewEpicID)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
templateInstantiateCmd.Flags().StringSlice("var", []string{}, "Variable substitution (key=value)")
|
|
templateInstantiateCmd.Flags().Bool("dry-run", false, "Preview what would be created")
|
|
templateInstantiateCmd.Flags().String("assignee", "", "Assign the root epic to this agent/user")
|
|
|
|
templateCmd.AddCommand(templateListCmd)
|
|
templateCmd.AddCommand(templateShowCmd)
|
|
templateCmd.AddCommand(templateInstantiateCmd)
|
|
rootCmd.AddCommand(templateCmd)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Beads Template Functions
|
|
// =============================================================================
|
|
|
|
// loadTemplateSubgraph loads a template epic and all its descendants
|
|
func loadTemplateSubgraph(ctx context.Context, s storage.Storage, templateID string) (*TemplateSubgraph, error) {
|
|
if s == nil {
|
|
return nil, fmt.Errorf("no database connection")
|
|
}
|
|
|
|
// Get the root issue
|
|
root, err := s.GetIssue(ctx, templateID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get template: %w", err)
|
|
}
|
|
if root == nil {
|
|
return nil, fmt.Errorf("template %s not found", templateID)
|
|
}
|
|
|
|
subgraph := &TemplateSubgraph{
|
|
Root: root,
|
|
Issues: []*types.Issue{root},
|
|
IssueMap: map[string]*types.Issue{root.ID: root},
|
|
}
|
|
|
|
// Recursively load all children
|
|
if err := loadDescendants(ctx, s, subgraph, root.ID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load all dependencies within the subgraph
|
|
for _, issue := range subgraph.Issues {
|
|
deps, err := s.GetDependencyRecords(ctx, issue.ID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get dependencies for %s: %w", issue.ID, err)
|
|
}
|
|
for _, dep := range deps {
|
|
// Only include dependencies where both ends are in the subgraph
|
|
if _, ok := subgraph.IssueMap[dep.DependsOnID]; ok {
|
|
subgraph.Dependencies = append(subgraph.Dependencies, dep)
|
|
}
|
|
}
|
|
}
|
|
|
|
return subgraph, nil
|
|
}
|
|
|
|
// loadDescendants recursively loads all child issues
|
|
// It uses two strategies to find children:
|
|
// 1. Check dependency records for parent-child relationships
|
|
// 2. Check for hierarchical IDs (parent.N) to catch children with missing/wrong deps (bd-c8d5)
|
|
func loadDescendants(ctx context.Context, s storage.Storage, subgraph *TemplateSubgraph, parentID string) error {
|
|
// Track children we've already added to avoid duplicates
|
|
addedChildren := make(map[string]bool)
|
|
|
|
// Strategy 1: GetDependents returns issues that depend on parentID
|
|
dependents, err := s.GetDependents(ctx, parentID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get dependents of %s: %w", parentID, err)
|
|
}
|
|
|
|
// Check each dependent to see if it's a child (has parent-child relationship)
|
|
for _, dependent := range dependents {
|
|
if _, exists := subgraph.IssueMap[dependent.ID]; exists {
|
|
continue // Already in subgraph
|
|
}
|
|
|
|
// Check if this dependent has a parent-child relationship with parentID
|
|
depRecs, err := s.GetDependencyRecords(ctx, dependent.ID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
isChild := false
|
|
for _, depRec := range depRecs {
|
|
if depRec.DependsOnID == parentID && depRec.Type == types.DepParentChild {
|
|
isChild = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isChild {
|
|
continue
|
|
}
|
|
|
|
// Add to subgraph
|
|
subgraph.Issues = append(subgraph.Issues, dependent)
|
|
subgraph.IssueMap[dependent.ID] = dependent
|
|
addedChildren[dependent.ID] = true
|
|
|
|
// Recurse to get children of this child
|
|
if err := loadDescendants(ctx, s, subgraph, dependent.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// Strategy 2: Find hierarchical children by ID pattern (bd-c8d5)
|
|
// This catches children that have missing or incorrect dependency types.
|
|
// Hierarchical IDs follow the pattern: parentID.N (e.g., "gt-abc.1", "gt-abc.2")
|
|
hierarchicalChildren, err := findHierarchicalChildren(ctx, s, parentID)
|
|
if err != nil {
|
|
// Non-fatal: continue with what we have
|
|
return nil
|
|
}
|
|
|
|
for _, child := range hierarchicalChildren {
|
|
if addedChildren[child.ID] {
|
|
continue // Already added via dependency
|
|
}
|
|
if _, exists := subgraph.IssueMap[child.ID]; exists {
|
|
continue // Already in subgraph
|
|
}
|
|
|
|
// Add to subgraph
|
|
subgraph.Issues = append(subgraph.Issues, child)
|
|
subgraph.IssueMap[child.ID] = child
|
|
addedChildren[child.ID] = true
|
|
|
|
// Recurse to get children of this child
|
|
if err := loadDescendants(ctx, s, subgraph, child.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// findHierarchicalChildren finds issues with IDs that match the pattern parentID.N
|
|
// This catches hierarchical children that may be missing parent-child dependencies.
|
|
func findHierarchicalChildren(ctx context.Context, s storage.Storage, parentID string) ([]*types.Issue, error) {
|
|
// Look for issues with IDs starting with "parentID."
|
|
// We need to query by ID pattern, which requires listing issues
|
|
pattern := parentID + "."
|
|
|
|
// Use the storage's search capability with a filter
|
|
allIssues, err := s.SearchIssues(ctx, "", types.IssueFilter{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var children []*types.Issue
|
|
for _, issue := range allIssues {
|
|
// Check if ID starts with pattern and is a direct child (no further dots after the pattern)
|
|
if len(issue.ID) > len(pattern) && issue.ID[:len(pattern)] == pattern {
|
|
// Check it's a direct child, not a grandchild
|
|
// e.g., "parent.1" is a child, "parent.1.2" is a grandchild
|
|
remaining := issue.ID[len(pattern):]
|
|
if !strings.Contains(remaining, ".") {
|
|
children = append(children, issue)
|
|
}
|
|
}
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Proto Lookup Functions (bd-drcx)
|
|
// =============================================================================
|
|
|
|
// resolveProtoIDOrTitle resolves a proto by ID or title.
|
|
// It first tries to resolve as an ID (via ResolvePartialID).
|
|
// If that fails, it searches for protos with matching titles.
|
|
// Returns the proto ID if found, or an error if not found or ambiguous.
|
|
func resolveProtoIDOrTitle(ctx context.Context, s storage.Storage, input string) (string, error) {
|
|
// Strategy 1: Try to resolve as an ID
|
|
protoID, err := utils.ResolvePartialID(ctx, s, input)
|
|
if err == nil {
|
|
// Verify it's a proto (has template label)
|
|
issue, getErr := s.GetIssue(ctx, protoID)
|
|
if getErr == nil && issue != nil {
|
|
labels, _ := s.GetLabels(ctx, protoID)
|
|
for _, label := range labels {
|
|
if label == BeadsTemplateLabel {
|
|
return protoID, nil // Found a valid proto by ID
|
|
}
|
|
}
|
|
}
|
|
// ID resolved but not a proto - continue to title search
|
|
}
|
|
|
|
// Strategy 2: Search for protos by title
|
|
protos, err := s.GetIssuesByLabel(ctx, BeadsTemplateLabel)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to search protos: %w", err)
|
|
}
|
|
|
|
var matches []*types.Issue
|
|
var exactMatch *types.Issue
|
|
|
|
for _, proto := range protos {
|
|
// Check for exact title match (case-insensitive)
|
|
if strings.EqualFold(proto.Title, input) {
|
|
exactMatch = proto
|
|
break
|
|
}
|
|
// Check for partial title match (case-insensitive)
|
|
if strings.Contains(strings.ToLower(proto.Title), strings.ToLower(input)) {
|
|
matches = append(matches, proto)
|
|
}
|
|
}
|
|
|
|
if exactMatch != nil {
|
|
return exactMatch.ID, nil
|
|
}
|
|
|
|
if len(matches) == 0 {
|
|
return "", fmt.Errorf("no proto found matching %q (by ID or title)", input)
|
|
}
|
|
|
|
if len(matches) == 1 {
|
|
return matches[0].ID, nil
|
|
}
|
|
|
|
// Multiple matches - show them all for disambiguation
|
|
var matchNames []string
|
|
for _, m := range matches {
|
|
matchNames = append(matchNames, fmt.Sprintf("%s: %s", m.ID, m.Title))
|
|
}
|
|
return "", fmt.Errorf("ambiguous: %q matches %d protos:\n %s\nUse the ID or a more specific title", input, len(matches), strings.Join(matchNames, "\n "))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Daemon-compatible Template Functions
|
|
// =============================================================================
|
|
|
|
// IssueDetailsFromShow represents the response structure from daemon Show RPC
|
|
type IssueDetailsFromShow struct {
|
|
types.Issue
|
|
Labels []string `json:"labels,omitempty"`
|
|
Dependencies []*types.IssueWithDependencyMetadata `json:"dependencies,omitempty"`
|
|
Dependents []*types.IssueWithDependencyMetadata `json:"dependents,omitempty"`
|
|
}
|
|
|
|
// loadTemplateSubgraphViaDaemon loads a template subgraph using daemon RPC calls
|
|
func loadTemplateSubgraphViaDaemon(client *rpc.Client, templateID string) (*TemplateSubgraph, error) {
|
|
// Get root issue with dependencies/dependents
|
|
resp, err := client.Show(&rpc.ShowArgs{ID: templateID})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get template: %w", err)
|
|
}
|
|
|
|
var rootDetails IssueDetailsFromShow
|
|
if err := json.Unmarshal(resp.Data, &rootDetails); err != nil {
|
|
return nil, fmt.Errorf("failed to parse template: %w", err)
|
|
}
|
|
|
|
root := &rootDetails.Issue
|
|
subgraph := &TemplateSubgraph{
|
|
Root: root,
|
|
Issues: []*types.Issue{root},
|
|
IssueMap: map[string]*types.Issue{root.ID: root},
|
|
}
|
|
|
|
// Find children from dependents (those with parent-child relationship)
|
|
// and recursively load them
|
|
if err := loadDescendantsViaDaemon(client, subgraph, rootDetails.Dependents); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Now build dependencies list by examining each issue's dependencies
|
|
// We need to get the dependency records, which Show provides
|
|
for _, issue := range subgraph.Issues {
|
|
resp, err := client.Show(&rpc.ShowArgs{ID: issue.ID})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var details IssueDetailsFromShow
|
|
if err := json.Unmarshal(resp.Data, &details); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Dependencies are issues that THIS issue depends on
|
|
for _, dep := range details.Dependencies {
|
|
// Only include if the dependency target is also in the subgraph
|
|
if _, ok := subgraph.IssueMap[dep.Issue.ID]; ok {
|
|
subgraph.Dependencies = append(subgraph.Dependencies, &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: dep.Issue.ID,
|
|
Type: dep.DependencyType,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return subgraph, nil
|
|
}
|
|
|
|
// loadDescendantsViaDaemon recursively loads child issues via daemon RPC
|
|
func loadDescendantsViaDaemon(client *rpc.Client, subgraph *TemplateSubgraph, dependents []*types.IssueWithDependencyMetadata) error {
|
|
for _, dep := range dependents {
|
|
// Check if this is a child (parent-child relationship)
|
|
if dep.DependencyType != types.DepParentChild {
|
|
continue
|
|
}
|
|
|
|
if _, exists := subgraph.IssueMap[dep.Issue.ID]; exists {
|
|
continue // Already in subgraph
|
|
}
|
|
|
|
// Add to subgraph
|
|
issue := &dep.Issue
|
|
subgraph.Issues = append(subgraph.Issues, issue)
|
|
subgraph.IssueMap[issue.ID] = issue
|
|
|
|
// Get this issue's dependents for recursion
|
|
resp, err := client.Show(&rpc.ShowArgs{ID: issue.ID})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var details IssueDetailsFromShow
|
|
if err := json.Unmarshal(resp.Data, &details); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Recurse on children
|
|
if err := loadDescendantsViaDaemon(client, subgraph, details.Dependents); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// cloneSubgraphViaDaemon creates new issues from the template using daemon RPC calls
|
|
func cloneSubgraphViaDaemon(client *rpc.Client, subgraph *TemplateSubgraph, opts CloneOptions) (*InstantiateResult, error) {
|
|
// Generate new IDs and create mapping
|
|
idMapping := make(map[string]string)
|
|
|
|
// First pass: create all issues with new IDs
|
|
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 && opts.Assignee != "" {
|
|
issueAssignee = opts.Assignee
|
|
}
|
|
|
|
// Build create args
|
|
createArgs := &rpc.CreateArgs{
|
|
Title: substituteVariables(oldIssue.Title, opts.Vars),
|
|
Description: substituteVariables(oldIssue.Description, opts.Vars),
|
|
IssueType: string(oldIssue.IssueType),
|
|
Priority: oldIssue.Priority,
|
|
Design: substituteVariables(oldIssue.Design, opts.Vars),
|
|
AcceptanceCriteria: substituteVariables(oldIssue.AcceptanceCriteria, opts.Vars),
|
|
Assignee: issueAssignee,
|
|
EstimatedMinutes: oldIssue.EstimatedMinutes,
|
|
Wisp: opts.Wisp,
|
|
IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps
|
|
}
|
|
|
|
// 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 nil, fmt.Errorf("failed to generate bonded ID for %s: %w", oldIssue.ID, err)
|
|
}
|
|
createArgs.ID = bondedID
|
|
}
|
|
|
|
resp, err := client.Create(createArgs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create issue from %s: %w", oldIssue.ID, err)
|
|
}
|
|
|
|
// Parse response to get the new issue ID
|
|
var newIssue types.Issue
|
|
if err := json.Unmarshal(resp.Data, &newIssue); err != nil {
|
|
return nil, fmt.Errorf("failed to parse created issue: %w", err)
|
|
}
|
|
|
|
idMapping[oldIssue.ID] = newIssue.ID
|
|
}
|
|
|
|
// Second pass: recreate dependencies with new IDs
|
|
for _, dep := range subgraph.Dependencies {
|
|
newFromID, ok1 := idMapping[dep.IssueID]
|
|
newToID, ok2 := idMapping[dep.DependsOnID]
|
|
if !ok1 || !ok2 {
|
|
continue // Skip if either end is outside the subgraph
|
|
}
|
|
|
|
_, err := client.AddDependency(&rpc.DepAddArgs{
|
|
FromID: newFromID,
|
|
ToID: newToID,
|
|
DepType: string(dep.Type),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create dependency: %w", err)
|
|
}
|
|
}
|
|
|
|
return &InstantiateResult{
|
|
NewEpicID: idMapping[subgraph.Root.ID],
|
|
IDMapping: idMapping,
|
|
Created: len(subgraph.Issues),
|
|
}, nil
|
|
}
|
|
|
|
// extractVariables finds all {{variable}} patterns in text
|
|
func extractVariables(text string) []string {
|
|
matches := variablePattern.FindAllStringSubmatch(text, -1)
|
|
seen := make(map[string]bool)
|
|
var vars []string
|
|
for _, match := range matches {
|
|
if len(match) >= 2 && !seen[match[1]] {
|
|
vars = append(vars, match[1])
|
|
seen[match[1]] = true
|
|
}
|
|
}
|
|
return vars
|
|
}
|
|
|
|
// extractAllVariables finds all variables across the entire subgraph
|
|
func extractAllVariables(subgraph *TemplateSubgraph) []string {
|
|
allText := ""
|
|
for _, issue := range subgraph.Issues {
|
|
allText += issue.Title + " " + issue.Description + " "
|
|
allText += issue.Design + " " + issue.AcceptanceCriteria + " " + issue.Notes + " "
|
|
}
|
|
return extractVariables(allText)
|
|
}
|
|
|
|
// substituteVariables replaces {{variable}} with values
|
|
func substituteVariables(text string, vars map[string]string) string {
|
|
return variablePattern.ReplaceAllStringFunc(text, func(match string) string {
|
|
// Extract variable name from {{name}}
|
|
name := match[2 : len(match)-2]
|
|
if val, ok := vars[name]; ok {
|
|
return val
|
|
}
|
|
return match // Leave unchanged if not found
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// Generate new IDs and create mapping
|
|
idMapping := make(map[string]string)
|
|
|
|
// Use transaction for atomicity
|
|
err := s.RunInTransaction(ctx, func(tx storage.Transaction) error {
|
|
// First pass: create all issues with new IDs
|
|
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 && opts.Assignee != "" {
|
|
issueAssignee = opts.Assignee
|
|
}
|
|
|
|
newIssue := &types.Issue{
|
|
// 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: opts.Wisp, // bd-2vh3: mark for cleanup when closed
|
|
IDPrefix: opts.Prefix, // bd-hobo: distinct prefixes for mols/wisps
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
idMapping[oldIssue.ID] = newIssue.ID
|
|
}
|
|
|
|
// Second pass: recreate dependencies with new IDs
|
|
for _, dep := range subgraph.Dependencies {
|
|
newFromID, ok1 := idMapping[dep.IssueID]
|
|
newToID, ok2 := idMapping[dep.DependsOnID]
|
|
if !ok1 || !ok2 {
|
|
continue // Skip if either end is outside the subgraph
|
|
}
|
|
|
|
newDep := &types.Dependency{
|
|
IssueID: newFromID,
|
|
DependsOnID: newToID,
|
|
Type: dep.Type,
|
|
}
|
|
if err := tx.AddDependency(ctx, newDep, opts.Actor); err != nil {
|
|
return fmt.Errorf("failed to create dependency: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &InstantiateResult{
|
|
NewEpicID: idMapping[subgraph.Root.ID],
|
|
IDMapping: idMapping,
|
|
Created: len(subgraph.Issues),
|
|
}, nil
|
|
}
|
|
|
|
// printTemplateTree prints the template structure as a tree
|
|
func printTemplateTree(subgraph *TemplateSubgraph, parentID string, depth int, isRoot bool) {
|
|
indent := strings.Repeat(" ", depth)
|
|
|
|
// Print root
|
|
if isRoot {
|
|
fmt.Printf("%s %s (root)\n", indent, subgraph.Root.Title)
|
|
}
|
|
|
|
// Find children of this parent
|
|
var children []*types.Issue
|
|
for _, dep := range subgraph.Dependencies {
|
|
if dep.DependsOnID == parentID && dep.Type == types.DepParentChild {
|
|
if child, ok := subgraph.IssueMap[dep.IssueID]; ok {
|
|
children = append(children, child)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Print children
|
|
for i, child := range children {
|
|
connector := "├──"
|
|
if i == len(children)-1 {
|
|
connector = "└──"
|
|
}
|
|
vars := extractVariables(child.Title)
|
|
varStr := ""
|
|
if len(vars) > 0 {
|
|
varStr = fmt.Sprintf(" [%s]", strings.Join(vars, ", "))
|
|
}
|
|
fmt.Printf("%s %s %s%s\n", indent, connector, child.Title, varStr)
|
|
printTemplateTree(subgraph, child.ID, depth+1, false)
|
|
}
|
|
}
|