From 309997576b9bca45da7f489817efcf9cee82e282 Mon Sep 17 00:00:00 2001 From: Valient Gough Date: Tue, 16 Dec 2025 17:05:45 -0800 Subject: [PATCH 1/2] add visual create-form command --- cmd/bd/create_form.go | 359 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 17 +- go.sum | 55 ++++++- 3 files changed, 423 insertions(+), 8 deletions(-) create mode 100644 cmd/bd/create_form.go diff --git a/cmd/bd/create_form.go b/cmd/bd/create_form.go new file mode 100644 index 00000000..4461a51b --- /dev/null +++ b/cmd/bd/create_form.go @@ -0,0 +1,359 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/charmbracelet/huh" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" +) + +var createFormCmd = &cobra.Command{ + Use: "create-form", + Short: "Create a new issue using an interactive form", + Long: `Create a new issue using an interactive terminal form. + +This command provides a user-friendly form interface for creating issues, +with fields for title, description, type, priority, labels, and more. + +The form uses keyboard navigation: + - Tab/Shift+Tab: Move between fields + - Enter: Submit the form (on the last field or submit button) + - Ctrl+C: Cancel and exit + - Arrow keys: Navigate within select fields`, + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("create-form") + runCreateForm(cmd) + }, +} + +func runCreateForm(cmd *cobra.Command) { + // Form field values + var ( + title string + description string + issueType string + priorityStr string + assignee string + labelsInput string + design string + acceptance string + externalRef string + depsInput string + ) + + // Issue type options + typeOptions := []huh.Option[string]{ + huh.NewOption("Task", "task"), + huh.NewOption("Bug", "bug"), + huh.NewOption("Feature", "feature"), + huh.NewOption("Epic", "epic"), + huh.NewOption("Chore", "chore"), + } + + // Priority options + priorityOptions := []huh.Option[string]{ + huh.NewOption("P0 - Critical", "0"), + huh.NewOption("P1 - High", "1"), + huh.NewOption("P2 - Medium (default)", "2"), + huh.NewOption("P3 - Low", "3"), + huh.NewOption("P4 - Backlog", "4"), + } + + // Build the form + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("Title"). + Description("Brief summary of the issue (required)"). + Placeholder("e.g., Fix authentication bug in login handler"). + Value(&title). + Validate(func(s string) error { + if strings.TrimSpace(s) == "" { + return fmt.Errorf("title is required") + } + if len(s) > 500 { + return fmt.Errorf("title must be 500 characters or less") + } + return nil + }), + + huh.NewText(). + Title("Description"). + Description("Detailed context about the issue"). + Placeholder("Explain why this issue exists and what needs to be done..."). + CharLimit(5000). + Value(&description), + + huh.NewSelect[string](). + Title("Type"). + Description("Categorize the kind of work"). + Options(typeOptions...). + Value(&issueType), + + huh.NewSelect[string](). + Title("Priority"). + Description("Set urgency level"). + Options(priorityOptions...). + Value(&priorityStr), + ), + + huh.NewGroup( + huh.NewInput(). + Title("Assignee"). + Description("Who should work on this? (optional)"). + Placeholder("username or email"). + Value(&assignee), + + huh.NewInput(). + Title("Labels"). + Description("Comma-separated tags (optional)"). + Placeholder("e.g., urgent, backend, needs-review"). + Value(&labelsInput), + + huh.NewInput(). + Title("External Reference"). + Description("Link to external tracker (optional)"). + Placeholder("e.g., gh-123, jira-ABC-456"). + Value(&externalRef), + ), + + huh.NewGroup( + huh.NewText(). + Title("Design Notes"). + Description("Technical approach or design details (optional)"). + Placeholder("Describe the implementation approach..."). + CharLimit(5000). + Value(&design), + + huh.NewText(). + Title("Acceptance Criteria"). + Description("How do we know this is done? (optional)"). + Placeholder("List the criteria for completion..."). + CharLimit(5000). + Value(&acceptance), + ), + + huh.NewGroup( + huh.NewInput(). + Title("Dependencies"). + Description("Format: type:id or just id (optional)"). + Placeholder("e.g., discovered-from:bd-20, blocks:bd-15"). + Value(&depsInput), + + huh.NewConfirm(). + Title("Create this issue?"). + Affirmative("Create"). + Negative("Cancel"), + ), + ).WithTheme(huh.ThemeDracula()) + + err := form.Run() + if err != nil { + if err == huh.ErrUserAborted { + fmt.Fprintln(os.Stderr, "Issue creation cancelled.") + os.Exit(0) + } + FatalError("form error: %v", err) + } + + // Parse priority + priority, err := strconv.Atoi(priorityStr) + if err != nil { + priority = 2 // Default to medium if parsing fails + } + + // Parse labels + var labels []string + if labelsInput != "" { + for _, l := range strings.Split(labelsInput, ",") { + l = strings.TrimSpace(l) + if l != "" { + labels = append(labels, l) + } + } + } + + // Parse dependencies + var deps []string + if depsInput != "" { + for _, d := range strings.Split(depsInput, ",") { + d = strings.TrimSpace(d) + if d != "" { + deps = append(deps, d) + } + } + } + + // Create the issue + var externalRefPtr *string + if externalRef != "" { + externalRefPtr = &externalRef + } + + // If daemon is running, use RPC + if daemonClient != nil { + createArgs := &rpc.CreateArgs{ + Title: title, + Description: description, + IssueType: issueType, + Priority: priority, + Design: design, + AcceptanceCriteria: acceptance, + Assignee: assignee, + ExternalRef: externalRef, + Labels: labels, + Dependencies: deps, + } + + resp, err := daemonClient.Create(createArgs) + if err != nil { + FatalError("%v", err) + } + + if jsonOutput { + fmt.Println(string(resp.Data)) + } else { + var issue types.Issue + if err := json.Unmarshal(resp.Data, &issue); err != nil { + FatalError("parsing response: %v", err) + } + printCreatedIssue(&issue) + } + return + } + + // Direct mode + issue := &types.Issue{ + Title: title, + Description: description, + Design: design, + AcceptanceCriteria: acceptance, + Status: types.StatusOpen, + Priority: priority, + IssueType: types.IssueType(issueType), + Assignee: assignee, + ExternalRef: externalRefPtr, + } + + ctx := rootCtx + + // 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 + } + + 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 && dependsOnID != "" { + 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 err := store.CreateIssue(ctx, issue, actor); err != nil { + FatalError("%v", err) + } + + // Add labels if specified + for _, label := range labels { + if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil { + WarnError("failed to add label %s: %v", label, err) + } + } + + // Add dependencies if specified + 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 { + WarnError("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec) + continue + } + depType = types.DependencyType(strings.TrimSpace(parts[0])) + dependsOnID = strings.TrimSpace(parts[1]) + } else { + depType = types.DepBlocks + dependsOnID = depSpec + } + + if !depType.IsValid() { + WarnError("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType) + continue + } + + dep := &types.Dependency{ + IssueID: issue.ID, + DependsOnID: dependsOnID, + Type: depType, + } + if err := store.AddDependency(ctx, dep, actor); err != nil { + WarnError("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err) + } + } + + // Schedule auto-flush + markDirtyAndScheduleFlush() + + if jsonOutput { + outputJSON(issue) + } else { + printCreatedIssue(issue) + } +} + +func printCreatedIssue(issue *types.Issue) { + green := color.New(color.FgGreen).SprintFunc() + fmt.Printf("\n%s Created issue: %s\n", green("✓"), issue.ID) + fmt.Printf(" Title: %s\n", issue.Title) + fmt.Printf(" Type: %s\n", issue.IssueType) + fmt.Printf(" Priority: P%d\n", issue.Priority) + fmt.Printf(" Status: %s\n", issue.Status) + if issue.Assignee != "" { + fmt.Printf(" Assignee: %s\n", issue.Assignee) + } + if issue.Description != "" { + desc := issue.Description + if len(desc) > 100 { + desc = desc[:97] + "..." + } + fmt.Printf(" Description: %s\n", desc) + } +} + +func init() { + // Note: --json flag is defined as a persistent flag in main.go + rootCmd.AddCommand(createFormCmd) +} diff --git a/go.mod b/go.mod index a6955c25..eabf5493 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.11 require ( github.com/anthropics/anthropic-sdk-go v1.19.0 + github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 @@ -22,18 +23,29 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect @@ -50,6 +62,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 65600b65..034f61b6 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,52 @@ +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/anthropics/anthropic-sdk-go v1.19.0 h1:mO6E+ffSzLRvR/YUH9KJC0uGw0uV8GjISIuzem//3KE= github.com/anthropics/anthropic-sdk-go v1.19.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -38,8 +70,16 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.30.3 h1:X/CgWW9GzmIAkEPrifhKqf0cC15DuOVxAJaHFTTAURQ= @@ -91,10 +131,13 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= From 3770fe4eebbc320c0c0acb02d0b0ee66ccc075d5 Mon Sep 17 00:00:00 2001 From: Valient Gough Date: Tue, 16 Dec 2025 17:26:06 -0800 Subject: [PATCH 2/2] cleanup --- cmd/bd/create_form.go | 368 +++++++++++++---------- cmd/bd/create_form_test.go | 599 +++++++++++++++++++++++++++++++++++++ 2 files changed, 809 insertions(+), 158 deletions(-) create mode 100644 cmd/bd/create_form_test.go diff --git a/cmd/bd/create_form.go b/cmd/bd/create_form.go index 4461a51b..b772c13f 100644 --- a/cmd/bd/create_form.go +++ b/cmd/bd/create_form.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "fmt" "os" @@ -11,9 +12,191 @@ import ( "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/types" ) +// createFormRawInput holds the raw string values from the form UI. +// This struct encapsulates all form fields before parsing/conversion. +type createFormRawInput struct { + Title string + Description string + IssueType string + Priority string // String from select, e.g., "0", "1", "2" + Assignee string + Labels string // Comma-separated + Design string + Acceptance string + ExternalRef string + Deps string // Comma-separated, format: "type:id" or "id" +} + +// createFormValues holds the parsed values from the create-form input. +// This struct is used to pass form data to the issue creation logic, +// allowing the creation logic to be tested independently of the form UI. +type createFormValues struct { + Title string + Description string + IssueType string + Priority int + Assignee string + Labels []string + Design string + AcceptanceCriteria string + ExternalRef string + Dependencies []string +} + +// parseCreateFormInput parses raw form input into a createFormValues struct. +// It handles comma-separated labels and dependencies, and converts priority strings. +func parseCreateFormInput(raw *createFormRawInput) *createFormValues { + // Parse priority + priority, err := strconv.Atoi(raw.Priority) + if err != nil { + priority = 2 // Default to medium if parsing fails + } + + // Parse labels + var labels []string + if raw.Labels != "" { + for _, l := range strings.Split(raw.Labels, ",") { + l = strings.TrimSpace(l) + if l != "" { + labels = append(labels, l) + } + } + } + + // Parse dependencies + var deps []string + if raw.Deps != "" { + for _, d := range strings.Split(raw.Deps, ",") { + d = strings.TrimSpace(d) + if d != "" { + deps = append(deps, d) + } + } + } + + return &createFormValues{ + Title: raw.Title, + Description: raw.Description, + IssueType: raw.IssueType, + Priority: priority, + Assignee: raw.Assignee, + Labels: labels, + Design: raw.Design, + AcceptanceCriteria: raw.Acceptance, + ExternalRef: raw.ExternalRef, + Dependencies: deps, + } +} + +// CreateIssueFromFormValues creates an issue from the given form values. +// It returns the created issue and any error that occurred. +// This function handles labels, dependencies, and source_repo inheritance. +func CreateIssueFromFormValues(ctx context.Context, s storage.Storage, fv *createFormValues, actor string) (*types.Issue, error) { + var externalRefPtr *string + if fv.ExternalRef != "" { + externalRefPtr = &fv.ExternalRef + } + + issue := &types.Issue{ + Title: fv.Title, + Description: fv.Description, + Design: fv.Design, + AcceptanceCriteria: fv.AcceptanceCriteria, + Status: types.StatusOpen, + Priority: fv.Priority, + IssueType: types.IssueType(fv.IssueType), + Assignee: fv.Assignee, + ExternalRef: externalRefPtr, + } + + // Check if any dependencies are discovered-from type + // If so, inherit source_repo from the parent issue + var discoveredFromParentID string + for _, depSpec := range fv.Dependencies { + depSpec = strings.TrimSpace(depSpec) + if depSpec == "" { + continue + } + + 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 && dependsOnID != "" { + discoveredFromParentID = dependsOnID + break + } + } + } + } + + // If we found a discovered-from dependency, inherit source_repo from parent + if discoveredFromParentID != "" { + parentIssue, err := s.GetIssue(ctx, discoveredFromParentID) + if err == nil && parentIssue != nil && parentIssue.SourceRepo != "" { + issue.SourceRepo = parentIssue.SourceRepo + } + } + + if err := s.CreateIssue(ctx, issue, actor); err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + + // Add labels if specified + for _, label := range fv.Labels { + if err := s.AddLabel(ctx, issue.ID, label, actor); err != nil { + // Log warning but don't fail the entire operation + fmt.Fprintf(os.Stderr, "Warning: failed to add label %s: %v\n", label, err) + } + } + + // Add dependencies if specified + for _, depSpec := range fv.Dependencies { + 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 { + 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 { + depType = types.DepBlocks + dependsOnID = depSpec + } + + if !depType.IsValid() { + fmt.Fprintf(os.Stderr, "Warning: invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)\n", depType) + continue + } + + dep := &types.Dependency{ + IssueID: issue.ID, + DependsOnID: dependsOnID, + Type: depType, + } + if err := s.AddDependency(ctx, dep, actor); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to add dependency %s -> %s: %v\n", issue.ID, dependsOnID, err) + } + } + + return issue, nil +} + var createFormCmd = &cobra.Command{ Use: "create-form", Short: "Create a new issue using an interactive form", @@ -34,19 +217,8 @@ The form uses keyboard navigation: } func runCreateForm(cmd *cobra.Command) { - // Form field values - var ( - title string - description string - issueType string - priorityStr string - assignee string - labelsInput string - design string - acceptance string - externalRef string - depsInput string - ) + // Raw form input - will be populated by the form + raw := &createFormRawInput{} // Issue type options typeOptions := []huh.Option[string]{ @@ -73,7 +245,7 @@ func runCreateForm(cmd *cobra.Command) { Title("Title"). Description("Brief summary of the issue (required)"). Placeholder("e.g., Fix authentication bug in login handler"). - Value(&title). + Value(&raw.Title). Validate(func(s string) error { if strings.TrimSpace(s) == "" { return fmt.Errorf("title is required") @@ -89,19 +261,19 @@ func runCreateForm(cmd *cobra.Command) { Description("Detailed context about the issue"). Placeholder("Explain why this issue exists and what needs to be done..."). CharLimit(5000). - Value(&description), + Value(&raw.Description), huh.NewSelect[string](). Title("Type"). Description("Categorize the kind of work"). Options(typeOptions...). - Value(&issueType), + Value(&raw.IssueType), huh.NewSelect[string](). Title("Priority"). Description("Set urgency level"). Options(priorityOptions...). - Value(&priorityStr), + Value(&raw.Priority), ), huh.NewGroup( @@ -109,19 +281,19 @@ func runCreateForm(cmd *cobra.Command) { Title("Assignee"). Description("Who should work on this? (optional)"). Placeholder("username or email"). - Value(&assignee), + Value(&raw.Assignee), huh.NewInput(). Title("Labels"). Description("Comma-separated tags (optional)"). Placeholder("e.g., urgent, backend, needs-review"). - Value(&labelsInput), + Value(&raw.Labels), huh.NewInput(). Title("External Reference"). Description("Link to external tracker (optional)"). Placeholder("e.g., gh-123, jira-ABC-456"). - Value(&externalRef), + Value(&raw.ExternalRef), ), huh.NewGroup( @@ -130,14 +302,14 @@ func runCreateForm(cmd *cobra.Command) { Description("Technical approach or design details (optional)"). Placeholder("Describe the implementation approach..."). CharLimit(5000). - Value(&design), + Value(&raw.Design), huh.NewText(). Title("Acceptance Criteria"). Description("How do we know this is done? (optional)"). Placeholder("List the criteria for completion..."). CharLimit(5000). - Value(&acceptance), + Value(&raw.Acceptance), ), huh.NewGroup( @@ -145,7 +317,7 @@ func runCreateForm(cmd *cobra.Command) { Title("Dependencies"). Description("Format: type:id or just id (optional)"). Placeholder("e.g., discovered-from:bd-20, blocks:bd-15"). - Value(&depsInput), + Value(&raw.Deps), huh.NewConfirm(). Title("Create this issue?"). @@ -163,53 +335,22 @@ func runCreateForm(cmd *cobra.Command) { FatalError("form error: %v", err) } - // Parse priority - priority, err := strconv.Atoi(priorityStr) - if err != nil { - priority = 2 // Default to medium if parsing fails - } - - // Parse labels - var labels []string - if labelsInput != "" { - for _, l := range strings.Split(labelsInput, ",") { - l = strings.TrimSpace(l) - if l != "" { - labels = append(labels, l) - } - } - } - - // Parse dependencies - var deps []string - if depsInput != "" { - for _, d := range strings.Split(depsInput, ",") { - d = strings.TrimSpace(d) - if d != "" { - deps = append(deps, d) - } - } - } - - // Create the issue - var externalRefPtr *string - if externalRef != "" { - externalRefPtr = &externalRef - } + // Parse the form input + fv := parseCreateFormInput(raw) // If daemon is running, use RPC if daemonClient != nil { createArgs := &rpc.CreateArgs{ - Title: title, - Description: description, - IssueType: issueType, - Priority: priority, - Design: design, - AcceptanceCriteria: acceptance, - Assignee: assignee, - ExternalRef: externalRef, - Labels: labels, - Dependencies: deps, + Title: fv.Title, + Description: fv.Description, + IssueType: fv.IssueType, + Priority: fv.Priority, + Design: fv.Design, + AcceptanceCriteria: fv.AcceptanceCriteria, + Assignee: fv.Assignee, + ExternalRef: fv.ExternalRef, + Labels: fv.Labels, + Dependencies: fv.Dependencies, } resp, err := daemonClient.Create(createArgs) @@ -229,101 +370,12 @@ func runCreateForm(cmd *cobra.Command) { return } - // Direct mode - issue := &types.Issue{ - Title: title, - Description: description, - Design: design, - AcceptanceCriteria: acceptance, - Status: types.StatusOpen, - Priority: priority, - IssueType: types.IssueType(issueType), - Assignee: assignee, - ExternalRef: externalRefPtr, - } - - ctx := rootCtx - - // 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 - } - - 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 && dependsOnID != "" { - 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 err := store.CreateIssue(ctx, issue, actor); err != nil { + // Direct mode - use the extracted creation function + issue, err := CreateIssueFromFormValues(rootCtx, store, fv, actor) + if err != nil { FatalError("%v", err) } - // Add labels if specified - for _, label := range labels { - if err := store.AddLabel(ctx, issue.ID, label, actor); err != nil { - WarnError("failed to add label %s: %v", label, err) - } - } - - // Add dependencies if specified - 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 { - WarnError("invalid dependency format '%s', expected 'type:id' or 'id'", depSpec) - continue - } - depType = types.DependencyType(strings.TrimSpace(parts[0])) - dependsOnID = strings.TrimSpace(parts[1]) - } else { - depType = types.DepBlocks - dependsOnID = depSpec - } - - if !depType.IsValid() { - WarnError("invalid dependency type '%s' (valid: blocks, related, parent-child, discovered-from)", depType) - continue - } - - dep := &types.Dependency{ - IssueID: issue.ID, - DependsOnID: dependsOnID, - Type: depType, - } - if err := store.AddDependency(ctx, dep, actor); err != nil { - WarnError("failed to add dependency %s -> %s: %v", issue.ID, dependsOnID, err) - } - } - // Schedule auto-flush markDirtyAndScheduleFlush() diff --git a/cmd/bd/create_form_test.go b/cmd/bd/create_form_test.go new file mode 100644 index 00000000..815522f7 --- /dev/null +++ b/cmd/bd/create_form_test.go @@ -0,0 +1,599 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestParseFormInput(t *testing.T) { + t.Run("BasicParsing", func(t *testing.T) { + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Test Title", + Description: "Test Description", + IssueType: "bug", + Priority: "1", + Assignee: "alice", + }) + + if fv.Title != "Test Title" { + t.Errorf("expected title 'Test Title', got %q", fv.Title) + } + if fv.Description != "Test Description" { + t.Errorf("expected description 'Test Description', got %q", fv.Description) + } + if fv.IssueType != "bug" { + t.Errorf("expected issue type 'bug', got %q", fv.IssueType) + } + if fv.Priority != 1 { + t.Errorf("expected priority 1, got %d", fv.Priority) + } + if fv.Assignee != "alice" { + t.Errorf("expected assignee 'alice', got %q", fv.Assignee) + } + }) + + t.Run("PriorityParsing", func(t *testing.T) { + // Valid priority + fv := parseCreateFormInput(&createFormRawInput{Title: "Title", IssueType: "task", Priority: "0"}) + if fv.Priority != 0 { + t.Errorf("expected priority 0, got %d", fv.Priority) + } + + // Invalid priority defaults to 2 + fv = parseCreateFormInput(&createFormRawInput{Title: "Title", IssueType: "task", Priority: "invalid"}) + if fv.Priority != 2 { + t.Errorf("expected default priority 2 for invalid input, got %d", fv.Priority) + } + + // Empty priority defaults to 2 + fv = parseCreateFormInput(&createFormRawInput{Title: "Title", IssueType: "task", Priority: ""}) + if fv.Priority != 2 { + t.Errorf("expected default priority 2 for empty input, got %d", fv.Priority) + } + }) + + t.Run("LabelsParsing", func(t *testing.T) { + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Title", + IssueType: "task", + Priority: "2", + Labels: "bug, critical, needs-review", + }) + + if len(fv.Labels) != 3 { + t.Fatalf("expected 3 labels, got %d", len(fv.Labels)) + } + + expected := []string{"bug", "critical", "needs-review"} + for i, label := range expected { + if fv.Labels[i] != label { + t.Errorf("expected label %q at index %d, got %q", label, i, fv.Labels[i]) + } + } + }) + + t.Run("LabelsWithEmptyValues", func(t *testing.T) { + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Title", + IssueType: "task", + Priority: "2", + Labels: "bug, , critical, ", + }) + + if len(fv.Labels) != 2 { + t.Fatalf("expected 2 non-empty labels, got %d: %v", len(fv.Labels), fv.Labels) + } + }) + + t.Run("DependenciesParsing", func(t *testing.T) { + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Title", + IssueType: "task", + Priority: "2", + Deps: "discovered-from:bd-20, blocks:bd-15", + }) + + if len(fv.Dependencies) != 2 { + t.Fatalf("expected 2 dependencies, got %d", len(fv.Dependencies)) + } + + expected := []string{"discovered-from:bd-20", "blocks:bd-15"} + for i, dep := range expected { + if fv.Dependencies[i] != dep { + t.Errorf("expected dependency %q at index %d, got %q", dep, i, fv.Dependencies[i]) + } + } + }) + + t.Run("AllFields", func(t *testing.T) { + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Full Issue", + Description: "Detailed description", + IssueType: "feature", + Priority: "1", + Assignee: "bob", + Labels: "frontend, urgent", + Design: "Use React hooks", + Acceptance: "Tests pass, UI works", + ExternalRef: "gh-123", + Deps: "blocks:bd-1", + }) + + if fv.Title != "Full Issue" { + t.Errorf("unexpected title: %q", fv.Title) + } + if fv.Description != "Detailed description" { + t.Errorf("unexpected description: %q", fv.Description) + } + if fv.IssueType != "feature" { + t.Errorf("unexpected issue type: %q", fv.IssueType) + } + if fv.Priority != 1 { + t.Errorf("unexpected priority: %d", fv.Priority) + } + if fv.Assignee != "bob" { + t.Errorf("unexpected assignee: %q", fv.Assignee) + } + if len(fv.Labels) != 2 { + t.Errorf("unexpected labels count: %d", len(fv.Labels)) + } + if fv.Design != "Use React hooks" { + t.Errorf("unexpected design: %q", fv.Design) + } + if fv.AcceptanceCriteria != "Tests pass, UI works" { + t.Errorf("unexpected acceptance criteria: %q", fv.AcceptanceCriteria) + } + if fv.ExternalRef != "gh-123" { + t.Errorf("unexpected external ref: %q", fv.ExternalRef) + } + if len(fv.Dependencies) != 1 { + t.Errorf("unexpected dependencies count: %d", len(fv.Dependencies)) + } + }) +} + +func TestCreateIssueFromFormValues(t *testing.T) { + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + ctx := context.Background() + + t.Run("BasicIssue", func(t *testing.T) { + fv := &createFormValues{ + Title: "Test Form Issue", + Priority: 1, + IssueType: "bug", + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + if issue.Title != "Test Form Issue" { + t.Errorf("expected title 'Test Form Issue', got %q", issue.Title) + } + if issue.Priority != 1 { + t.Errorf("expected priority 1, got %d", issue.Priority) + } + if issue.IssueType != types.TypeBug { + t.Errorf("expected type bug, got %s", issue.IssueType) + } + if issue.Status != types.StatusOpen { + t.Errorf("expected status open, got %s", issue.Status) + } + }) + + t.Run("WithDescription", func(t *testing.T) { + fv := &createFormValues{ + Title: "Issue with description", + Description: "This is a detailed description", + Priority: 2, + IssueType: "task", + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + if issue.Description != "This is a detailed description" { + t.Errorf("expected description, got %q", issue.Description) + } + }) + + t.Run("WithDesignAndAcceptance", func(t *testing.T) { + fv := &createFormValues{ + Title: "Feature with design", + Design: "Use MVC pattern", + AcceptanceCriteria: "All tests pass", + IssueType: "feature", + Priority: 2, + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + if issue.Design != "Use MVC pattern" { + t.Errorf("expected design, got %q", issue.Design) + } + if issue.AcceptanceCriteria != "All tests pass" { + t.Errorf("expected acceptance criteria, got %q", issue.AcceptanceCriteria) + } + }) + + t.Run("WithAssignee", func(t *testing.T) { + fv := &createFormValues{ + Title: "Assigned issue", + Assignee: "alice", + Priority: 1, + IssueType: "task", + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + if issue.Assignee != "alice" { + t.Errorf("expected assignee 'alice', got %q", issue.Assignee) + } + }) + + t.Run("WithExternalRef", func(t *testing.T) { + fv := &createFormValues{ + Title: "Issue with external ref", + ExternalRef: "gh-123", + Priority: 2, + IssueType: "bug", + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + if issue.ExternalRef == nil { + t.Fatal("expected external ref to be set") + } + if *issue.ExternalRef != "gh-123" { + t.Errorf("expected external ref 'gh-123', got %q", *issue.ExternalRef) + } + }) + + t.Run("WithLabels", func(t *testing.T) { + fv := &createFormValues{ + Title: "Issue with labels", + Priority: 0, + IssueType: "bug", + Labels: []string{"bug", "critical"}, + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + labels, err := s.GetLabels(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to get labels: %v", err) + } + + if len(labels) != 2 { + t.Errorf("expected 2 labels, got %d", len(labels)) + } + + labelMap := make(map[string]bool) + for _, l := range labels { + labelMap[l] = true + } + + if !labelMap["bug"] || !labelMap["critical"] { + t.Errorf("expected labels 'bug' and 'critical', got %v", labels) + } + }) + + t.Run("WithDependencies", func(t *testing.T) { + // Create a parent issue first + parentFv := &createFormValues{ + Title: "Parent issue for deps", + Priority: 1, + IssueType: "task", + } + parent, err := CreateIssueFromFormValues(ctx, s, parentFv, "test") + if err != nil { + t.Fatalf("failed to create parent: %v", err) + } + + // Create child with dependency + childFv := &createFormValues{ + Title: "Child issue", + Priority: 1, + IssueType: "task", + Dependencies: []string{parent.ID}, // Default blocks type + } + child, err := CreateIssueFromFormValues(ctx, s, childFv, "test") + if err != nil { + t.Fatalf("failed to create child: %v", err) + } + + deps, err := s.GetDependencies(ctx, child.ID) + if err != nil { + t.Fatalf("failed to get dependencies: %v", err) + } + + if len(deps) == 0 { + t.Fatal("expected at least 1 dependency, got 0") + } + + found := false + for _, d := range deps { + if d.ID == parent.ID { + found = true + break + } + } + + if !found { + t.Errorf("expected dependency on %s, not found", parent.ID) + } + }) + + t.Run("WithTypedDependencies", func(t *testing.T) { + // Create a parent issue + parentFv := &createFormValues{ + Title: "Related parent", + Priority: 1, + IssueType: "task", + } + parent, err := CreateIssueFromFormValues(ctx, s, parentFv, "test") + if err != nil { + t.Fatalf("failed to create parent: %v", err) + } + + // Create child with typed dependency + childFv := &createFormValues{ + Title: "Child with typed dep", + Priority: 1, + IssueType: "bug", + Dependencies: []string{"discovered-from:" + parent.ID}, + } + child, err := CreateIssueFromFormValues(ctx, s, childFv, "test") + if err != nil { + t.Fatalf("failed to create child: %v", err) + } + + deps, err := s.GetDependencies(ctx, child.ID) + if err != nil { + t.Fatalf("failed to get dependencies: %v", err) + } + + if len(deps) == 0 { + t.Fatal("expected at least 1 dependency, got 0") + } + + found := false + for _, d := range deps { + if d.ID == parent.ID { + found = true + break + } + } + + if !found { + t.Errorf("expected dependency on %s, not found", parent.ID) + } + }) + + t.Run("AllIssueTypes", func(t *testing.T) { + issueTypes := []string{"bug", "feature", "task", "epic", "chore"} + expectedTypes := []types.IssueType{ + types.TypeBug, + types.TypeFeature, + types.TypeTask, + types.TypeEpic, + types.TypeChore, + } + + for i, issueType := range issueTypes { + fv := &createFormValues{ + Title: "Test " + issueType, + IssueType: issueType, + Priority: 2, + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue type %s: %v", issueType, err) + } + + if issue.IssueType != expectedTypes[i] { + t.Errorf("expected type %s, got %s", expectedTypes[i], issue.IssueType) + } + } + }) + + t.Run("MultipleDependencies", func(t *testing.T) { + // Create two parent issues + parent1Fv := &createFormValues{ + Title: "Multi-dep Parent 1", + Priority: 1, + IssueType: "task", + } + parent1, err := CreateIssueFromFormValues(ctx, s, parent1Fv, "test") + if err != nil { + t.Fatalf("failed to create parent1: %v", err) + } + + parent2Fv := &createFormValues{ + Title: "Multi-dep Parent 2", + Priority: 1, + IssueType: "task", + } + parent2, err := CreateIssueFromFormValues(ctx, s, parent2Fv, "test") + if err != nil { + t.Fatalf("failed to create parent2: %v", err) + } + + // Create child with multiple dependencies + childFv := &createFormValues{ + Title: "Multi-dep Child", + Priority: 1, + IssueType: "task", + Dependencies: []string{"blocks:" + parent1.ID, "related:" + parent2.ID}, + } + child, err := CreateIssueFromFormValues(ctx, s, childFv, "test") + if err != nil { + t.Fatalf("failed to create child: %v", err) + } + + deps, err := s.GetDependencies(ctx, child.ID) + if err != nil { + t.Fatalf("failed to get dependencies: %v", err) + } + + if len(deps) < 2 { + t.Fatalf("expected at least 2 dependencies, got %d", len(deps)) + } + + foundParents := make(map[string]bool) + for _, d := range deps { + if d.ID == parent1.ID || d.ID == parent2.ID { + foundParents[d.ID] = true + } + } + + if len(foundParents) != 2 { + t.Errorf("expected to find both parent dependencies, found %d", len(foundParents)) + } + }) + + t.Run("DiscoveredFromInheritsSourceRepo", func(t *testing.T) { + // Create a parent issue with a custom source_repo + parent := &types.Issue{ + Title: "Parent with source repo", + Priority: 1, + Status: types.StatusOpen, + IssueType: types.TypeTask, + SourceRepo: "/path/to/custom/repo", + } + + if err := s.CreateIssue(ctx, parent, "test"); err != nil { + t.Fatalf("failed to create parent: %v", err) + } + + // Create a discovered issue with discovered-from dependency + childFv := &createFormValues{ + Title: "Discovered bug", + Priority: 1, + IssueType: "bug", + Dependencies: []string{"discovered-from:" + parent.ID}, + } + child, err := CreateIssueFromFormValues(ctx, s, childFv, "test") + if err != nil { + t.Fatalf("failed to create discovered issue: %v", err) + } + + // Verify the discovered issue inherited the source_repo + retrievedIssue, err := s.GetIssue(ctx, child.ID) + if err != nil { + t.Fatalf("failed to get discovered issue: %v", err) + } + + if retrievedIssue.SourceRepo != parent.SourceRepo { + t.Errorf("expected source_repo %q, got %q", parent.SourceRepo, retrievedIssue.SourceRepo) + } + }) + + t.Run("AllPriorities", func(t *testing.T) { + for priority := 0; priority <= 4; priority++ { + fv := &createFormValues{ + Title: "Priority test", + IssueType: "task", + Priority: priority, + } + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue with priority %d: %v", priority, err) + } + + if issue.Priority != priority { + t.Errorf("expected priority %d, got %d", priority, issue.Priority) + } + } + }) +} + +func TestFormValuesIntegration(t *testing.T) { + // Test the full flow: parseCreateFormInput -> CreateIssueFromFormValues + tmpDir := t.TempDir() + testDB := filepath.Join(tmpDir, ".beads", "beads.db") + s := newTestStore(t, testDB) + ctx := context.Background() + + t.Run("FullFlow", func(t *testing.T) { + // Simulate form input + fv := parseCreateFormInput(&createFormRawInput{ + Title: "Integration Test Issue", + Description: "Testing the full flow from form to storage", + IssueType: "feature", + Priority: "1", + Assignee: "test-user", + Labels: "integration, test", + Design: "Design notes here", + Acceptance: "Should work end to end", + ExternalRef: "gh-999", + }) + + issue, err := CreateIssueFromFormValues(ctx, s, fv, "test") + if err != nil { + t.Fatalf("failed to create issue: %v", err) + } + + // Verify issue was stored + retrieved, err := s.GetIssue(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to retrieve issue: %v", err) + } + + if retrieved.Title != "Integration Test Issue" { + t.Errorf("unexpected title: %q", retrieved.Title) + } + if retrieved.Description != "Testing the full flow from form to storage" { + t.Errorf("unexpected description: %q", retrieved.Description) + } + if retrieved.IssueType != types.TypeFeature { + t.Errorf("unexpected type: %s", retrieved.IssueType) + } + if retrieved.Priority != 1 { + t.Errorf("unexpected priority: %d", retrieved.Priority) + } + if retrieved.Assignee != "test-user" { + t.Errorf("unexpected assignee: %q", retrieved.Assignee) + } + if retrieved.Design != "Design notes here" { + t.Errorf("unexpected design: %q", retrieved.Design) + } + if retrieved.AcceptanceCriteria != "Should work end to end" { + t.Errorf("unexpected acceptance criteria: %q", retrieved.AcceptanceCriteria) + } + if retrieved.ExternalRef == nil || *retrieved.ExternalRef != "gh-999" { + t.Errorf("unexpected external ref: %v", retrieved.ExternalRef) + } + + // Check labels + labels, err := s.GetLabels(ctx, issue.ID) + if err != nil { + t.Fatalf("failed to get labels: %v", err) + } + if len(labels) != 2 { + t.Errorf("expected 2 labels, got %d", len(labels)) + } + }) +}