This commit is contained in:
Valient Gough
2025-12-16 17:26:06 -08:00
parent 309997576b
commit 3770fe4eeb
2 changed files with 809 additions and 158 deletions

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
@@ -11,9 +12,191 @@ import (
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/types" "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{ var createFormCmd = &cobra.Command{
Use: "create-form", Use: "create-form",
Short: "Create a new issue using an interactive form", Short: "Create a new issue using an interactive form",
@@ -34,19 +217,8 @@ The form uses keyboard navigation:
} }
func runCreateForm(cmd *cobra.Command) { func runCreateForm(cmd *cobra.Command) {
// Form field values // Raw form input - will be populated by the form
var ( raw := &createFormRawInput{}
title string
description string
issueType string
priorityStr string
assignee string
labelsInput string
design string
acceptance string
externalRef string
depsInput string
)
// Issue type options // Issue type options
typeOptions := []huh.Option[string]{ typeOptions := []huh.Option[string]{
@@ -73,7 +245,7 @@ func runCreateForm(cmd *cobra.Command) {
Title("Title"). Title("Title").
Description("Brief summary of the issue (required)"). Description("Brief summary of the issue (required)").
Placeholder("e.g., Fix authentication bug in login handler"). Placeholder("e.g., Fix authentication bug in login handler").
Value(&title). Value(&raw.Title).
Validate(func(s string) error { Validate(func(s string) error {
if strings.TrimSpace(s) == "" { if strings.TrimSpace(s) == "" {
return fmt.Errorf("title is required") return fmt.Errorf("title is required")
@@ -89,19 +261,19 @@ func runCreateForm(cmd *cobra.Command) {
Description("Detailed context about the issue"). Description("Detailed context about the issue").
Placeholder("Explain why this issue exists and what needs to be done..."). Placeholder("Explain why this issue exists and what needs to be done...").
CharLimit(5000). CharLimit(5000).
Value(&description), Value(&raw.Description),
huh.NewSelect[string](). huh.NewSelect[string]().
Title("Type"). Title("Type").
Description("Categorize the kind of work"). Description("Categorize the kind of work").
Options(typeOptions...). Options(typeOptions...).
Value(&issueType), Value(&raw.IssueType),
huh.NewSelect[string](). huh.NewSelect[string]().
Title("Priority"). Title("Priority").
Description("Set urgency level"). Description("Set urgency level").
Options(priorityOptions...). Options(priorityOptions...).
Value(&priorityStr), Value(&raw.Priority),
), ),
huh.NewGroup( huh.NewGroup(
@@ -109,19 +281,19 @@ func runCreateForm(cmd *cobra.Command) {
Title("Assignee"). Title("Assignee").
Description("Who should work on this? (optional)"). Description("Who should work on this? (optional)").
Placeholder("username or email"). Placeholder("username or email").
Value(&assignee), Value(&raw.Assignee),
huh.NewInput(). huh.NewInput().
Title("Labels"). Title("Labels").
Description("Comma-separated tags (optional)"). Description("Comma-separated tags (optional)").
Placeholder("e.g., urgent, backend, needs-review"). Placeholder("e.g., urgent, backend, needs-review").
Value(&labelsInput), Value(&raw.Labels),
huh.NewInput(). huh.NewInput().
Title("External Reference"). Title("External Reference").
Description("Link to external tracker (optional)"). Description("Link to external tracker (optional)").
Placeholder("e.g., gh-123, jira-ABC-456"). Placeholder("e.g., gh-123, jira-ABC-456").
Value(&externalRef), Value(&raw.ExternalRef),
), ),
huh.NewGroup( huh.NewGroup(
@@ -130,14 +302,14 @@ func runCreateForm(cmd *cobra.Command) {
Description("Technical approach or design details (optional)"). Description("Technical approach or design details (optional)").
Placeholder("Describe the implementation approach..."). Placeholder("Describe the implementation approach...").
CharLimit(5000). CharLimit(5000).
Value(&design), Value(&raw.Design),
huh.NewText(). huh.NewText().
Title("Acceptance Criteria"). Title("Acceptance Criteria").
Description("How do we know this is done? (optional)"). Description("How do we know this is done? (optional)").
Placeholder("List the criteria for completion..."). Placeholder("List the criteria for completion...").
CharLimit(5000). CharLimit(5000).
Value(&acceptance), Value(&raw.Acceptance),
), ),
huh.NewGroup( huh.NewGroup(
@@ -145,7 +317,7 @@ func runCreateForm(cmd *cobra.Command) {
Title("Dependencies"). Title("Dependencies").
Description("Format: type:id or just id (optional)"). Description("Format: type:id or just id (optional)").
Placeholder("e.g., discovered-from:bd-20, blocks:bd-15"). Placeholder("e.g., discovered-from:bd-20, blocks:bd-15").
Value(&depsInput), Value(&raw.Deps),
huh.NewConfirm(). huh.NewConfirm().
Title("Create this issue?"). Title("Create this issue?").
@@ -163,53 +335,22 @@ func runCreateForm(cmd *cobra.Command) {
FatalError("form error: %v", err) FatalError("form error: %v", err)
} }
// Parse priority // Parse the form input
priority, err := strconv.Atoi(priorityStr) fv := parseCreateFormInput(raw)
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 daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
createArgs := &rpc.CreateArgs{ createArgs := &rpc.CreateArgs{
Title: title, Title: fv.Title,
Description: description, Description: fv.Description,
IssueType: issueType, IssueType: fv.IssueType,
Priority: priority, Priority: fv.Priority,
Design: design, Design: fv.Design,
AcceptanceCriteria: acceptance, AcceptanceCriteria: fv.AcceptanceCriteria,
Assignee: assignee, Assignee: fv.Assignee,
ExternalRef: externalRef, ExternalRef: fv.ExternalRef,
Labels: labels, Labels: fv.Labels,
Dependencies: deps, Dependencies: fv.Dependencies,
} }
resp, err := daemonClient.Create(createArgs) resp, err := daemonClient.Create(createArgs)
@@ -229,101 +370,12 @@ func runCreateForm(cmd *cobra.Command) {
return return
} }
// Direct mode // Direct mode - use the extracted creation function
issue := &types.Issue{ issue, err := CreateIssueFromFormValues(rootCtx, store, fv, actor)
Title: title, if err != nil {
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) 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 // Schedule auto-flush
markDirtyAndScheduleFlush() markDirtyAndScheduleFlush()

599
cmd/bd/create_form_test.go Normal file
View File

@@ -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))
}
})
}