package main import ( "context" "encoding/json" "fmt" "os" "strings" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" ) var createCmd = &cobra.Command{ Use: "create [title]", Short: "Create a new issue (or multiple issues from markdown file)", Args: cobra.MinimumNArgs(0), // Changed to allow no args when using -f Run: func(cmd *cobra.Command, args []string) { file, _ := cmd.Flags().GetString("file") fromTemplate, _ := cmd.Flags().GetString("from-template") // If file flag is provided, parse markdown and create multiple issues if file != "" { if len(args) > 0 { fmt.Fprintf(os.Stderr, "Error: cannot specify both title and --file flag\n") os.Exit(1) } createIssuesFromMarkdown(cmd, file) return } // Original single-issue creation logic // Get title from flag or positional argument titleFlag, _ := cmd.Flags().GetString("title") var title string if len(args) > 0 && titleFlag != "" { // Both provided - check if they match if args[0] != titleFlag { fmt.Fprintf(os.Stderr, "Error: cannot specify different titles as both positional argument and --title flag\n") fmt.Fprintf(os.Stderr, " Positional: %q\n", args[0]) fmt.Fprintf(os.Stderr, " --title: %q\n", titleFlag) os.Exit(1) } title = args[0] // They're the same, use either } else if len(args) > 0 { title = args[0] } else if titleFlag != "" { title = titleFlag } else { fmt.Fprintf(os.Stderr, "Error: title required (or use --file to create from markdown)\n") os.Exit(1) } // Load template if specified var tmpl *Template if fromTemplate != "" { var err error tmpl, err = loadTemplate(fromTemplate) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } // Get field values, preferring explicit flags over template defaults description, _ := cmd.Flags().GetString("description") if description == "" && tmpl != nil { description = tmpl.Description } design, _ := cmd.Flags().GetString("design") if design == "" && tmpl != nil { design = tmpl.Design } acceptance, _ := cmd.Flags().GetString("acceptance") if acceptance == "" && tmpl != nil { acceptance = tmpl.AcceptanceCriteria } // Parse priority (supports both "1" and "P1" formats) priorityStr, _ := cmd.Flags().GetString("priority") priority := parsePriority(priorityStr) if priority == -1 { fmt.Fprintf(os.Stderr, "Error: invalid priority %q (expected 0-4 or P0-P4)\n", priorityStr) os.Exit(1) } if cmd.Flags().Changed("priority") == false && tmpl != nil { priority = tmpl.Priority } issueType, _ := cmd.Flags().GetString("type") if !cmd.Flags().Changed("type") && tmpl != nil && tmpl.Type != "" { // Flag not explicitly set and template has a type, use template issueType = tmpl.Type } assignee, _ := cmd.Flags().GetString("assignee") labels, _ := cmd.Flags().GetStringSlice("labels") labelAlias, _ := cmd.Flags().GetStringSlice("label") if len(labelAlias) > 0 { labels = append(labels, labelAlias...) } if len(labels) == 0 && tmpl != nil && len(tmpl.Labels) > 0 { labels = tmpl.Labels } explicitID, _ := cmd.Flags().GetString("id") parentID, _ := cmd.Flags().GetString("parent") externalRef, _ := cmd.Flags().GetString("external-ref") deps, _ := cmd.Flags().GetStringSlice("deps") forceCreate, _ := cmd.Flags().GetBool("force") repoOverride, _ := cmd.Flags().GetString("repo") // Use global jsonOutput set by PersistentPreRun // Determine target repository using routing logic repoPath := "." // default to current directory if cmd.Flags().Changed("repo") { // Explicit --repo flag overrides auto-routing repoPath = repoOverride } else { // Auto-routing based on user role userRole, err := routing.DetectUserRole(".") if err != nil && os.Getenv("BD_DEBUG") != "" { fmt.Fprintf(os.Stderr, "Warning: failed to detect user role: %v\n", err) } routingConfig := &routing.RoutingConfig{ Mode: config.GetString("routing.mode"), DefaultRepo: config.GetString("routing.default"), MaintainerRepo: config.GetString("routing.maintainer"), ContributorRepo: config.GetString("routing.contributor"), ExplicitOverride: repoOverride, } repoPath = routing.DetermineTargetRepo(routingConfig, userRole, ".") } // TODO: Switch to target repo for multi-repo support (bd-4ms) // For now, we just log the target repo in debug mode if os.Getenv("BD_DEBUG") != "" && repoPath != "." { fmt.Fprintf(os.Stderr, "DEBUG: Target repo: %s\n", repoPath) } // Check for conflicting flags if explicitID != "" && parentID != "" { fmt.Fprintf(os.Stderr, "Error: cannot specify both --id and --parent flags\n") os.Exit(1) } // If parent is specified, generate child ID // In daemon mode, the parent will be sent to the RPC handler // In direct mode, we generate the child ID here if parentID != "" && daemonClient == nil { ctx := context.Background() childID, err := store.GetNextChildID(ctx, parentID) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } explicitID = childID // Set as explicit ID for the rest of the flow } // Validate explicit ID format if provided // Supports: prefix-number (bd-42), prefix-hash (bd-a3f8e9), or hierarchical (bd-a3f8e9.1) if explicitID != "" { // Must contain hyphen if !strings.Contains(explicitID, "-") { fmt.Fprintf(os.Stderr, "Error: invalid ID format '%s' (expected format: prefix-hash or prefix-hash.number, e.g., 'bd-a3f8e9' or 'bd-a3f8e9.1')\n", explicitID) os.Exit(1) } // Extract prefix (before the first hyphen) hyphenIdx := strings.Index(explicitID, "-") requestedPrefix := explicitID[:hyphenIdx] // Validate prefix matches database prefix (unless --force is used) if !forceCreate { ctx := context.Background() // Get database prefix from config var dbPrefix string if daemonClient != nil { // Using daemon - need to get config via RPC // For now, skip validation in daemon mode (needs RPC enhancement) } else { // Direct mode - check config dbPrefix, _ = store.GetConfig(ctx, "issue_prefix") } if dbPrefix != "" && dbPrefix != requestedPrefix { fmt.Fprintf(os.Stderr, "Error: prefix mismatch detected\n") fmt.Fprintf(os.Stderr, " This database uses prefix '%s', but you specified '%s'\n", dbPrefix, requestedPrefix) fmt.Fprintf(os.Stderr, " Use --force to create with mismatched prefix anyway\n") os.Exit(1) } } } var externalRefPtr *string if externalRef != "" { externalRefPtr = &externalRef } // If daemon is running, use RPC if daemonClient != nil { createArgs := &rpc.CreateArgs{ ID: explicitID, Parent: parentID, Title: title, Description: description, IssueType: issueType, Priority: priority, Design: design, AcceptanceCriteria: acceptance, Assignee: assignee, Labels: labels, Dependencies: deps, } resp, err := daemonClient.Create(createArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { fmt.Println(string(resp.Data)) } else { var issue types.Issue if err := json.Unmarshal(resp.Data, &issue); err != nil { fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err) os.Exit(1) } green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) } return } // Direct mode issue := &types.Issue{ ID: explicitID, // Set explicit ID if provided (empty string if not) Title: title, Description: description, Design: design, AcceptanceCriteria: acceptance, Status: types.StatusOpen, Priority: priority, IssueType: types.IssueType(issueType), Assignee: assignee, ExternalRef: externalRefPtr, } ctx := context.Background() // Check if any dependencies are discovered-from type // If so, inherit source_repo from the parent issue var discoveredFromParentID string for _, depSpec := range deps { depSpec = strings.TrimSpace(depSpec) if depSpec == "" { continue } var depType types.DependencyType var dependsOnID string if strings.Contains(depSpec, ":") { parts := strings.SplitN(depSpec, ":", 2) if len(parts) == 2 { depType = types.DependencyType(strings.TrimSpace(parts[0])) dependsOnID = strings.TrimSpace(parts[1]) if depType == types.DepDiscoveredFrom { discoveredFromParentID = dependsOnID break } } } } // If we found a discovered-from dependency, inherit source_repo from parent if discoveredFromParentID != "" { parentIssue, err := store.GetIssue(ctx, discoveredFromParentID) if err == nil && parentIssue.SourceRepo != "" { issue.SourceRepo = parentIssue.SourceRepo } // If error getting parent or parent has no source_repo, continue with default } if err := store.CreateIssue(ctx, issue, actor); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } // Add labels if specified for _, label := range labels { if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to add label %s: %v\n", label, err) } } // Add dependencies if specified (format: type:id or just id for default "blocks" type) for _, depSpec := range deps { // Skip empty specs (e.g., from trailing commas) depSpec = strings.TrimSpace(depSpec) if depSpec == "" { continue } var depType types.DependencyType var dependsOnID string // Parse format: "type:id" or just "id" (defaults to "blocks") if strings.Contains(depSpec, ":") { parts := strings.SplitN(depSpec, ":", 2) if len(parts) != 2 { fmt.Fprintf(os.Stderr, "Warning: invalid dependency format '%s', expected 'type:id' or 'id'\n", depSpec) continue } depType = types.DependencyType(strings.TrimSpace(parts[0])) dependsOnID = strings.TrimSpace(parts[1]) } else { // Default to "blocks" if no type specified depType = types.DepBlocks dependsOnID = depSpec } // Validate dependency type if !depType.IsValid() { fmt.Fprintf(os.Stderr, "Warning: invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)\n", depType) continue } // Add the dependency dep := &types.Dependency{ IssueID: issue.ID, DependsOnID: dependsOnID, Type: depType, } if err := store.AddDependency(ctx, dep, actor); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", issue.ID, dependsOnID, err) } } // Schedule auto-flush markDirtyAndScheduleFlush() if jsonOutput { outputJSON(issue) } else { green := color.New(color.FgGreen).SprintFunc() fmt.Printf("%s Created issue: %s\n", green("✓"), issue.ID) fmt.Printf(" Title: %s\n", issue.Title) fmt.Printf(" Priority: P%d\n", issue.Priority) fmt.Printf(" Status: %s\n", issue.Status) } }, } func init() { createCmd.Flags().StringP("file", "f", "", "Create multiple issues from markdown file") createCmd.Flags().String("from-template", "", "Create issue from template (e.g., 'epic', 'bug', 'feature')") createCmd.Flags().String("title", "", "Issue title (alternative to positional argument)") createCmd.Flags().StringP("description", "d", "", "Issue description") createCmd.Flags().String("design", "", "Design notes") createCmd.Flags().String("acceptance", "", "Acceptance criteria") createCmd.Flags().StringP("priority", "p", "2", "Priority (0-4 or P0-P4, 0=highest)") createCmd.Flags().StringP("type", "t", "task", "Issue type (bug|feature|task|epic|chore)") createCmd.Flags().StringP("assignee", "a", "", "Assignee") createCmd.Flags().StringSliceP("labels", "l", []string{}, "Labels (comma-separated)") createCmd.Flags().StringSlice("label", []string{}, "Alias for --labels") _ = createCmd.Flags().MarkHidden("label") createCmd.Flags().String("id", "", "Explicit issue ID (e.g., 'bd-42' for partitioning)") createCmd.Flags().String("parent", "", "Parent issue ID for hierarchical child (e.g., 'bd-a3f8e9')") createCmd.Flags().String("external-ref", "", "External reference (e.g., 'gh-9', 'jira-ABC')") createCmd.Flags().StringSlice("deps", []string{}, "Dependencies in format 'type:id' or 'id' (e.g., 'discovered-from:bd-20,blocks:bd-15' or 'bd-20')") createCmd.Flags().Bool("force", false, "Force creation even if prefix doesn't match database prefix") createCmd.Flags().String("repo", "", "Target repository for issue (overrides auto-routing)") // Note: --json flag is defined as a persistent flag in main.go, not here rootCmd.AddCommand(createCmd) }