412 lines
11 KiB
Go
412 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"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/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",
|
|
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) {
|
|
// Raw form input - will be populated by the form
|
|
raw := &createFormRawInput{}
|
|
|
|
// 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(&raw.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(&raw.Description),
|
|
|
|
huh.NewSelect[string]().
|
|
Title("Type").
|
|
Description("Categorize the kind of work").
|
|
Options(typeOptions...).
|
|
Value(&raw.IssueType),
|
|
|
|
huh.NewSelect[string]().
|
|
Title("Priority").
|
|
Description("Set urgency level").
|
|
Options(priorityOptions...).
|
|
Value(&raw.Priority),
|
|
),
|
|
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Assignee").
|
|
Description("Who should work on this? (optional)").
|
|
Placeholder("username or email").
|
|
Value(&raw.Assignee),
|
|
|
|
huh.NewInput().
|
|
Title("Labels").
|
|
Description("Comma-separated tags (optional)").
|
|
Placeholder("e.g., urgent, backend, needs-review").
|
|
Value(&raw.Labels),
|
|
|
|
huh.NewInput().
|
|
Title("External Reference").
|
|
Description("Link to external tracker (optional)").
|
|
Placeholder("e.g., gh-123, jira-ABC-456").
|
|
Value(&raw.ExternalRef),
|
|
),
|
|
|
|
huh.NewGroup(
|
|
huh.NewText().
|
|
Title("Design Notes").
|
|
Description("Technical approach or design details (optional)").
|
|
Placeholder("Describe the implementation approach...").
|
|
CharLimit(5000).
|
|
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(&raw.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(&raw.Deps),
|
|
|
|
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 the form input
|
|
fv := parseCreateFormInput(raw)
|
|
|
|
// If daemon is running, use RPC
|
|
if daemonClient != nil {
|
|
createArgs := &rpc.CreateArgs{
|
|
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)
|
|
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 - use the extracted creation function
|
|
issue, err := CreateIssueFromFormValues(rootCtx, store, fv, actor)
|
|
if err != nil {
|
|
FatalError("%v", 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)
|
|
}
|