From 3770fe4eebbc320c0c0acb02d0b0ee66ccc075d5 Mon Sep 17 00:00:00 2001 From: Valient Gough Date: Tue, 16 Dec 2025 17:26:06 -0800 Subject: [PATCH] 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)) + } + }) +}