diff --git a/Makefile b/Makefile index 2e82291..203455b 100644 --- a/Makefile +++ b/Makefile @@ -7,12 +7,12 @@ clean: go clean build: - go build -o ./bin/cli ./cmd/cli + go build -o ./bin/pm ./cmd/pm go build -o ./bin/tui ./cmd/tui go build -o ./bin/web ./cmd/web cli: - go run ./cmd/cli + go run ./cmd/pm tui: go run ./cmd/tui diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index c85a988..c3f515c 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -1,3 +1,4 @@ +// Package cli provides the command-line interface for the PM System. package cli import ( @@ -7,7 +8,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/cli/commands" ) -// CLIConfig is an alias for service.Config, which contains all the necessary +// CLIConfig is an alias for service.Config, used to configure the CLI. type CLIConfig = service.Config // Run initializes the services and executes the CLI commands. diff --git a/pkg/cli/commands/close.go b/pkg/cli/commands/close.go new file mode 100644 index 0000000..b4ff882 --- /dev/null +++ b/pkg/cli/commands/close.go @@ -0,0 +1,63 @@ +package commands + +import ( + "fmt" + + "github.com/charmbracelet/huh" + "github.com/spf13/cobra" +) + +// closeCmd represents the close command, +// which allows users to close an existing issue by its ID. +var closeCmd = &cobra.Command{ + Use: "close [id]", + Short: "Close an existing issue", + Long: `Close an existing issue by its ID.`, + Example: `pm close pm-abc`, + + Args: cobra.ExactArgs(1), + RunE: runCloseCmd, + + ValidArgsFunction: completeIssues, +} + +// runCloseCmd executes the close command logic, +// which closes an issue by its ID after confirming with the user. +func runCloseCmd(cmd *cobra.Command, args []string) error { + closeID := args[0] + + if closeID == "" { + return fmt.Errorf("issue ID cannot be empty") + } + + // Fetch the issue to ensure it exists before closing. + issue, err := svc.Beads.GetIssue(cmd.Context(), closeID) + if err != nil { + return fmt.Errorf("error fetching issue: %w", err) + } + + if issue == nil { + return fmt.Errorf("issue with ID %s not found", closeID) + } + + // Ask for closing reason + if err = huh.NewInput().Value(&issue.CloseReason). + Title("Reason for closing the issue?").WithTheme(huh.ThemeBase()).Run(); err != nil { + return fmt.Errorf("error getting close reason: %w", err) + } + + // Close the issue. + err = svc.Beads.CloseIssue(cmd.Context(), closeID, issue.CloseReason, "", "") + if err != nil { + return fmt.Errorf("error closing issue: %w", err) + } + + cmd.Println("Closed issue with ID:", closeID) + + return nil +} + +// init function to set up the close command and its flags. +func init() { + rootCmd.AddCommand(closeCmd) +} diff --git a/pkg/cli/commands/completion.go b/pkg/cli/commands/completion.go index ff13107..77d3fcf 100644 --- a/pkg/cli/commands/completion.go +++ b/pkg/cli/commands/completion.go @@ -8,6 +8,20 @@ import ( "github.com/spf13/cobra" ) +// Variables for completion options and functions. +var ( + typeOptions = []string{"bug", "feature", "task"} + statusOptions = []string{"open", "closed", "in_progress"} + priorityRange = []string{"0", "1", "2", "3", "4"} +) + +// completionFunc returns a function that provides shell completion for the given options. +func completionFunc(options []string) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return options, cobra.ShellCompDirectiveDefault + } +} + // completeIssues provides shell completion for issue IDs and titles. func completeIssues(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { issues, _ := GetIssueCompletions(cmd.Context(), toComplete) diff --git a/pkg/cli/commands/create.go b/pkg/cli/commands/create.go index 289c72f..538ed27 100644 --- a/pkg/cli/commands/create.go +++ b/pkg/cli/commands/create.go @@ -5,17 +5,13 @@ import ( "strings" "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) -// Variables to hold flag values for the create command. -var ( - createDescription string - createStatus string - createType string - createPriority int -) +// createFlags holds the flag values for the create command +var createFlags Flags const ( createCmdExample = `pm create New issue -d "Description" -s open -t task -p 3 @@ -29,25 +25,32 @@ var createCmd = &cobra.Command{ Long: `Create a new issue with the specified details.`, Example: createCmdExample, + Args: cobra.MinimumNArgs(0), Aliases: []string{"add"}, - Args: cobra.MinimumNArgs(1), RunE: runCreateCmd, } // runCreateCmd executes the create command logic, func runCreateCmd(cmd *cobra.Command, args []string) error { - createTitle := strings.Join(args, " ") + createFlags.title = strings.Join(args, " ") - if createTitle == "" { + // Run interactive if flag is set + if createFlags.interactive { + if err := runCreateInteractive(); err != nil { + return err + } + } + + if createFlags.title == "" { return fmt.Errorf("issue title cannot be empty") } issue := &models.Issue{ - Title: createTitle, - Description: createDescription, - Status: models.Status(createStatus), - IssueType: models.IssueType(createType), - Priority: createPriority, + Title: createFlags.title, + Description: createFlags.description, + Status: models.Status(createFlags.status), + IssueType: models.IssueType(createFlags.issueType), + Priority: createFlags.priority, } // Create the issue using the service layer. @@ -56,52 +59,60 @@ func runCreateCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("error creating issue: %w", err) } - // Build the output string with the created issue details. - str := fmt.Sprintf("Created issue with ID: %s\n", issue.ID) - - if issue.Title != "" { - str += fmt.Sprintf("Title: %s\n", issue.Title) - } - - if issue.Description != "" { - str += fmt.Sprintf("Description: %s\n", issue.Description) - } - - if issue.Status != "" { - str += fmt.Sprintf("Status: %s\n", issue.Status) - } - - if issue.IssueType != "" { - str += fmt.Sprintf("Type: %s\n", issue.IssueType) - } - - if issue.Priority != 0 { - str += fmt.Sprintf("Priority: %d\n", issue.Priority) - } - - cmd.Print(str) + // Display the created issue details to the user. + cmd.Printf("Created issue:\n%s", models.IssueString(*issue)) return nil } +// runCreateInteractive runs the interactive mode for creating issues, +// allowing users to input issue details through a form. +func runCreateInteractive() error { + form := huh.NewForm( + + huh.NewGroup( + huh.NewInput().Value(&createFlags.title).Title("Title"), + huh.NewText().Value(&createFlags.description).Title("Description")), + + huh.NewGroup( + huh.NewSelect[string]().Title("Status"). + Options( + huh.NewOption("Open", "open"), + huh.NewOption("Closed", "closed"), + huh.NewOption("In Progress", "in_progress"), + ).Value(&createFlags.status), + + huh.NewSelect[string]().Title("Type"). + Options( + huh.NewOption("Bug", "bug"), + huh.NewOption("Feature", "feature"), + huh.NewOption("Task", "task"), + ).Value(&createFlags.issueType), + + huh.NewSelect[int]().Title("Priority"). + Options( + huh.NewOption("0", 0), + huh.NewOption("1", 1), + huh.NewOption("2", 2), + huh.NewOption("3", 3), + huh.NewOption("4", 4), + ).Value(&createFlags.priority), + )).WithTheme(huh.ThemeBase16()) + + return form.Run() +} + // init function to set up the create command and its flags. func init() { - createCmd.Flags().StringVarP(&createDescription, "desc", "d", "", "Issue description") - createCmd.Flags().StringVarP(&createStatus, "status", "s", "open", "Issue status(open, closed, in_progress)") - createCmd.Flags().StringVarP(&createType, "type", "t", "task", "Issue type(bug, feature, task)") - createCmd.Flags().IntVarP(&createPriority, "priority", "p", 0, "Issue priority(0-5)") + createCmd.Flags().BoolVarP(&createFlags.interactive, "interactive", "i", false, "Create issue interactively") + createCmd.Flags().StringVarP(&createFlags.description, "desc", "d", "", "Issue description") + createCmd.Flags().StringVarP(&createFlags.status, "status", "s", "open", "Issue status(open, closed, in_progress)") + createCmd.Flags().StringVarP(&createFlags.issueType, "type", "t", "task", "Issue type(bug, feature, task)") + createCmd.Flags().IntVarP(&createFlags.priority, "priority", "p", 0, "Issue priority(0-4)") - createCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"bug", "feature", "task"}, cobra.ShellCompDirectiveDefault - }) - - createCmd.RegisterFlagCompletionFunc("status", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"open", "closed", "in_progress"}, cobra.ShellCompDirectiveDefault - }) - - createCmd.RegisterFlagCompletionFunc("priority", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"0", "1", "2", "3", "4", "5"}, cobra.ShellCompDirectiveDefault - }) + createCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + createCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + createCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) rootCmd.AddCommand(createCmd) } diff --git a/pkg/cli/commands/delete.go b/pkg/cli/commands/delete.go index ce2cc40..b96e659 100644 --- a/pkg/cli/commands/delete.go +++ b/pkg/cli/commands/delete.go @@ -1,15 +1,21 @@ package commands import ( + "context" "fmt" "strings" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/charmbracelet/huh" "github.com/spf13/cobra" ) // Variables for delete command flag. -var confirmDelete bool +var ( + confirmDelete bool + deleteIDs []string + deleteInteractive bool +) // deleteCmd represents the delete command. var deleteCmd = &cobra.Command{ @@ -18,8 +24,9 @@ var deleteCmd = &cobra.Command{ Long: `Delete an existing issue by its ID.`, Example: `pm delete pm-abc`, + ValidArgsFunction: completeIssues, + Aliases: []string{"del", "remove", "rm"}, - Args: cobra.ExactArgs(1), RunE: runDeleteCmd, } @@ -28,6 +35,17 @@ var deleteCmd = &cobra.Command{ func runDeleteCmd(cmd *cobra.Command, args []string) error { deleteID := strings.Join(args, " ") + if deleteInteractive { + if err := runDeleteInteractive(cmd.Context()); err != nil { + return err + } + return nil + } + + if deleteID == "" { + return fmt.Errorf("issue ID cannot be empty") + } + // Fetch the issue to ensure it exists before deletion. issue, err := svc.Beads.GetIssue(cmd.Context(), deleteID) if err != nil { @@ -62,8 +80,49 @@ func runDeleteCmd(cmd *cobra.Command, args []string) error { return nil } +// runDeleteInteractive runs the interactive mode for deleting issues, +// allowing users to select multiple issues for deletion. +func runDeleteInteractive(ctx context.Context) error { + options := []huh.Option[string]{} + + issues, err := svc.Beads.SearchIssues(ctx, "", models.IssueFilter{}) + if err != nil { + return fmt.Errorf("error fetching issues: %w", err) + } + + for _, issue := range issues { + desc := fmt.Sprintf("%s: %s", issue.ID, issue.Title) + options = append(options, huh.NewOption(desc, issue.ID)) + } + + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Options(options...).Value(&deleteIDs). + Title("Select issues to delete"))).WithTheme(huh.ThemeBase()) + + if err := form.Run(); err != nil { + return fmt.Errorf("error running interactive form: %w", err) + } + + if len(deleteIDs) == 0 { + return fmt.Errorf("no issues selected for deletion") + } + + for _, id := range deleteIDs { + err := svc.Beads.DeleteIssue(ctx, id) + if err != nil { + return fmt.Errorf("error deleting issue with ID %s: %w", id, err) + } + fmt.Printf("Deleted issue with ID: %s\n", id) + } + + return nil +} + // init function to set up the delete command and its flags. func init() { + deleteCmd.Flags().BoolVarP(&deleteInteractive, "interactive", "i", false, "Delete issues interactively") deleteCmd.Flags().BoolVarP(&confirmDelete, "yes", "y", true, "Confirm deletion without prompt") rootCmd.AddCommand(deleteCmd) diff --git a/pkg/cli/commands/ls.go b/pkg/cli/commands/ls.go index 5bd841c..bd65899 100644 --- a/pkg/cli/commands/ls.go +++ b/pkg/cli/commands/ls.go @@ -8,30 +8,23 @@ import ( ) // Variables for get-issues command flags. -var ( - titleFlag string - descriptionFlag string - statusFlag string - typeFlag string - priorityFlag int - limit int = 25 -) +var listFlags Flags const ( - lsExamples = `pm ls [id|title|description] -pm ls --status open --type bug -pm ls --title "New feature" --desc "feature description" -pm ls -p 1 -l 10` + lsExamples = `pm list [id|title|description] +pm list --status open --type bug +pm list --title "New feature" --desc "feature description" +pm list -p 1 -l 10` ) // getIssuesCmd represents the get issues command. var getIssuesCmd = &cobra.Command{ - Use: "ls [search query]", + Use: "list [search query]", Short: "List all issues", Long: `List all issues in the project management system.`, Example: lsExamples, - Aliases: []string{"list", "search"}, + Aliases: []string{"ls", "search"}, Args: cobra.MinimumNArgs(0), RunE: runGetIssuesCmd, } @@ -42,23 +35,23 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { queryArg := strings.Join(args, " ") filter := models.IssueFilter{ - TitleSearch: titleFlag, - DescriptionContains: descriptionFlag, - Limit: limit, + TitleSearch: listFlags.title, + DescriptionContains: listFlags.description, + Limit: listFlags.limit, } // Only set filter fields if the corresponding flags // were explicitly provided by the user. if cmd.Flags().Changed("status") { - s := models.Status(statusFlag) + s := models.Status(listFlags.status) filter.Status = &s } if cmd.Flags().Changed("type") { - t := models.IssueType(typeFlag) + t := models.IssueType(listFlags.issueType) filter.IssueType = &t } if cmd.Flags().Changed("priority") { - filter.Priority = &priorityFlag + filter.Priority = &listFlags.priority } // Fetch issues based on the search query and filters. @@ -76,24 +69,17 @@ func runGetIssuesCmd(cmd *cobra.Command, args []string) error { // init function to set up the get issues command and its flags. func init() { - getIssuesCmd.Flags().StringVar(&titleFlag, "title", "", "Filter issues by title") - getIssuesCmd.Flags().StringVarP(&descriptionFlag, "desc", "d", "", "Filter issues by description") - getIssuesCmd.Flags().StringVarP(&statusFlag, "status", "s", "", "Filter issues by status (open, closed, in_progress)") - getIssuesCmd.Flags().StringVarP(&typeFlag, "type", "t", "", "Filter issues by type (bug, feature, task)") - getIssuesCmd.Flags().IntVarP(&priorityFlag, "priority", "p", 0, "Filter issues by priority (0-5)") - getIssuesCmd.Flags().IntVarP(&limit, "limit", "l", 25, "Limit the number of issues returned") + getIssuesCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title") + getIssuesCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description") + getIssuesCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)") + getIssuesCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)") + getIssuesCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)") - getIssuesCmd.RegisterFlagCompletionFunc("status", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"open", "closed", "in_progress"}, cobra.ShellCompDirectiveDefault - }) + getIssuesCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned") - getIssuesCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"bug", "feature", "task"}, cobra.ShellCompDirectiveDefault - }) - - getIssuesCmd.RegisterFlagCompletionFunc("priority", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"0", "1", "2", "3", "4", "5"}, cobra.ShellCompDirectiveDefault - }) + getIssuesCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + getIssuesCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + getIssuesCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) rootCmd.AddCommand(getIssuesCmd) } diff --git a/pkg/cli/commands/root.go b/pkg/cli/commands/root.go index 418d7ae..9e18856 100644 --- a/pkg/cli/commands/root.go +++ b/pkg/cli/commands/root.go @@ -14,6 +14,18 @@ import ( // Must be called before executing any commands to ensure services are available. var svc *service.Services +// Flags struct to hold command-line flag values for issues. +type Flags struct { + interactive bool + limit int + + title string + description string + status string + issueType string + priority int +} + // rootCmd is the base command for the CLI application. var rootCmd = &cobra.Command{ Short: "Project Management CLI", @@ -56,7 +68,7 @@ func ExecuteArgsString(args []string) (string, error) { // init function to set up the command hierarchy and options. func init() { - rootCmd.CompletionOptions.DisableDefaultCmd = true + rootCmd.CompletionOptions.DisableDefaultCmd = false rootCmd.AddGroup(&cobra.Group{ID: "help", Title: "Helping Commands"}) rootCmd.SetCompletionCommandGroupID("help") rootCmd.SetHelpCommandGroupID("help") diff --git a/pkg/cli/commands/update.go b/pkg/cli/commands/update.go index 81c8cec..099a08c 100644 --- a/pkg/cli/commands/update.go +++ b/pkg/cli/commands/update.go @@ -3,16 +3,11 @@ package commands import ( "fmt" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/spf13/cobra" ) -var ( - updateDescription string - updateStatus string - updateType string - updatePriority int - updateTitle string -) +var updateFlags Flags var updateCmd = &cobra.Command{ Use: "update [issue ID]", @@ -25,67 +20,21 @@ var updateCmd = &cobra.Command{ ValidArgsFunction: completeIssues, } -func init() { - updateCmd.Flags().StringVar(&updateTitle, "title", "", "New issue title") - updateCmd.Flags().StringVarP(&updateDescription, "desc", "d", "", "New issue description") - updateCmd.Flags().StringVarP(&updateStatus, "status", "s", "", "New issue status(open, closed, in_progress)") - updateCmd.Flags().StringVarP(&updateType, "type", "", "", "New issue type(bug, feature, task)") - updateCmd.Flags().IntVarP(&updatePriority, "priority", "p", -1, "New issue priority(0-5)") - - updateCmd.RegisterFlagCompletionFunc("type", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"bug", "feature", "task"}, cobra.ShellCompDirectiveDefault - }) - - updateCmd.RegisterFlagCompletionFunc("status", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"open", "closed", "in_progress"}, cobra.ShellCompDirectiveDefault - }) - - updateCmd.RegisterFlagCompletionFunc("priority", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"0", "1", "2", "3", "4", "5"}, cobra.ShellCompDirectiveDefault - }) - - rootCmd.AddCommand(updateCmd) -} - func runUpdateCmd(cmd *cobra.Command, args []string) error { issueID := args[0] - updates := make(map[string]interface{}) - - if cmd.Flags().Changed("title") { - if updateTitle == "" { - return fmt.Errorf("issue title cannot be empty") - } - updates["title"] = updateTitle - } - - if cmd.Flags().Changed("desc") { - updates["description"] = updateDescription - } - - if cmd.Flags().Changed("status") { - updates["status"] = updateStatus - } - - if cmd.Flags().Changed("type") { - updates["issue_type"] = updateType - } - - if cmd.Flags().Changed("priority") { - updates["priority"] = updatePriority - } - - if len(updates) == 0 { - return fmt.Errorf("no updates specified") - } - issue, err := svc.Beads.GetIssue(cmd.Context(), issueID) if err != nil { return fmt.Errorf("error getting issue: %w", err) } if issue == nil { - return fmt.Errorf("issue with ID '%s' not found", issueID) + return fmt.Errorf("issue with ID %s not found", issueID) + } + + updates, err := getUpdateValues(cmd) + if err != nil { + return fmt.Errorf("error getting update values: %w", err) } err = svc.Beads.UpdateIssue(cmd.Context(), issueID, updates, "test_actor") @@ -98,29 +47,54 @@ func runUpdateCmd(cmd *cobra.Command, args []string) error { return fmt.Errorf("error getting updated issue: %w", err) } - str := fmt.Sprintf("Updated issue with ID: %s\n", issueID) - - if updatedIssue.Title != "" { - str += fmt.Sprintf("Title: %s\n", updatedIssue.Title) - } - - if updatedIssue.Description != "" { - str += fmt.Sprintf("Description: %s\n", updatedIssue.Description) - } - - if updatedIssue.Status != "" { - str += fmt.Sprintf("Status: %s\n", updatedIssue.Status) - } - - if updatedIssue.IssueType != "" { - str += fmt.Sprintf("Type: %s\n", updatedIssue.IssueType) - } - - if cmd.Flags().Changed("priority") { - str += fmt.Sprintf("Priority: %d\n", updatedIssue.Priority) - } - - cmd.Print(str) + cmd.Printf("Updated issue to:\n%s", models.IssueString(*updatedIssue)) return nil } + +func init() { + updateCmd.Flags().StringVar(&updateFlags.title, "title", "", "New issue title") + updateCmd.Flags().StringVarP(&updateFlags.description, "desc", "d", "", "New issue description") + updateCmd.Flags().StringVarP(&updateFlags.status, "status", "s", "", "New issue status(open, closed, in_progress)") + updateCmd.Flags().StringVarP(&updateFlags.issueType, "type", "t", "", "New issue type(bug, feature, task)") + updateCmd.Flags().IntVarP(&updateFlags.priority, "priority", "p", 0, "New issue priority(0-5)") + + updateCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions)) + updateCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions)) + updateCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange)) + + rootCmd.AddCommand(updateCmd) +} + +func getUpdateValues(cmd *cobra.Command) (map[string]interface{}, error) { + updates := make(map[string]interface{}) + + if cmd.Flags().Changed("title") { + if updateFlags.title == "" { + return updates, fmt.Errorf("issue title cannot be empty") + } + updates["title"] = updateFlags.title + } + + if cmd.Flags().Changed("desc") { + updates["description"] = updateFlags.description + } + + if cmd.Flags().Changed("status") { + updates["status"] = updateFlags.status + } + + if cmd.Flags().Changed("type") { + updates["issue_type"] = updateFlags.issueType + } + + if cmd.Flags().Changed("priority") { + updates["priority"] = updateFlags.priority + } + + if len(updates) == 0 { + return updates, fmt.Errorf("no updates specified") + } + + return updates, nil +} diff --git a/pkg/cli/repl/completer.go b/pkg/cli/repl/completer.go index 9cb119a..99d1b21 100644 --- a/pkg/cli/repl/completer.go +++ b/pkg/cli/repl/completer.go @@ -6,23 +6,28 @@ import ( "github.com/c-bata/go-prompt" ) +// completer provides suggestions for the REPL input based on the current input text. func completer(d prompt.Document) []prompt.Suggest { - text := d.TextBeforeCursor() - words := strings.Fields(text) + text := d.TextBeforeCursor() // Gets the text before the cursor as a string. + words := strings.Fields(text) // Split the string into words as []string. + // If there are no words, return no suggestions. if len(words) == 0 { return nil } + // If the first word is not "pm", only provide root-level suggestions. if words[0] != "pm" { return filterByPrefix(rootSuggestions, words[0]) } + // If the first word is "pm", provide command and flag suggestions based on the context. pmWords := words[1:] if len(words) == 1 || (len(pmWords) == 1 && !strings.HasSuffix(text, " ")) { return commandSuggestions(pmWords) } + // If the last word starts with a "-", provide flag suggestions for the current command. if len(pmWords) >= 1 { return flagSuggestions(pmWords[0], pmWords, text) } diff --git a/pkg/cli/repl/executor.go b/pkg/cli/repl/executor.go index 0ec939f..c0c50a7 100644 --- a/pkg/cli/repl/executor.go +++ b/pkg/cli/repl/executor.go @@ -7,6 +7,9 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/cli/commands" ) +// execute processes the input command and returns the output +// or an error if it occurs. It handles what type of command is being executed, +// whether it's a PM command or a shell command, and routes it accordingly. func execute(input string) (string, error) { if input == "" { return "", nil @@ -26,6 +29,8 @@ func execute(input string) (string, error) { return executeShellCommand(input) } +// executeShellCommand executes a shell command +// and returns its output or an error if it occurs. func executeShellCommand(input string) (string, error) { parts := strings.Fields(input) if len(parts) == 0 { @@ -37,6 +42,8 @@ func executeShellCommand(input string) (string, error) { return string(output), err } +// executePMCommand executes a PM command using the commands package +// and returns its output or an error if it occurs. func executePMCommand(input string) (string, error) { parts := strings.Fields(input) if len(parts) == 0 { diff --git a/pkg/cli/repl/options.go b/pkg/cli/repl/options.go index 7ba4e63..0d0918c 100644 --- a/pkg/cli/repl/options.go +++ b/pkg/cli/repl/options.go @@ -5,13 +5,8 @@ import "github.com/c-bata/go-prompt" const PromptPrefix = "> " const OptionMaxSuggestions = 5 -const ( - ReplHelp = `Type 'pm help' for available PM commands. -You can also run shell commands directly. Type 'exit' or 'quit' to leave.` - - ReplTitle = "Welcome to Project Management CLI! " + ReplHelp -) - +// promptOptions returns a slice of prompt.Option +// to configure the behavior and appearance of the REPL prompt. func promptOptions(history []string) []prompt.Option { return []prompt.Option{ prompt.OptionPrefixTextColor(prompt.Cyan), diff --git a/pkg/cli/repl/repl.go b/pkg/cli/repl/repl.go index b00fa75..cba9569 100644 --- a/pkg/cli/repl/repl.go +++ b/pkg/cli/repl/repl.go @@ -1,3 +1,4 @@ +// Package repl implements the Read-Eval-Print Loop (REPL) for the PM CLI. package repl import ( @@ -14,45 +15,63 @@ import ( "golang.org/x/term" ) +const ( + ReplHelp = `Type 'pm help' for available PM commands. +You can also run shell commands directly. Type 'exit' or 'quit' to leave.` + + ReplTitle = "Welcome to Project Management CLI! " + ReplHelp +) + +// RunREPL starts the interactive Read-Eval-Print Loop for the PM CLI. func RunREPL(ctx context.Context, config cli.CLIConfig) error { + // Set terminal to raw mode to capture input properly in the REPL. + // This allows us to handle input character by character and provide a better user experience. + // We also ensure that the terminal state is restored when the REPL exits, even if an error occurs. oldState, err := term.GetState(int(os.Stdin.Fd())) if err != nil { return fmt.Errorf("failed to get terminal state: %w", err) } defer term.Restore(int(os.Stdin.Fd()), oldState) + // Initialize services for beads, config and stats. svc, cleanup, err := service.NewServices(ctx, config) if err != nil { return fmt.Errorf("failed to initialize services: %w", err) } defer cleanup() + // Make sure to set services, to ensure they are available. commands.SetServices(svc) - fmt.Println(styles.TitleStyle.Render(ReplTitle)) + fmt.Println(styles.TitleStyle.Render(ReplTitle)) // Print REPL title. + // history keeps track of command history. + // This enables navigating through previous commands. var history []string + // Start the REPL loop, which continues until the user types "exit" or "quit". for { + // Prompt the user for input, and provide suggestions. input := prompt.Input( PromptPrefix, completer, promptOptions(history)..., ) + // Trim whitespace from the input to ensure consistent command processing. input = strings.TrimSpace(input) + // If the user types "exit" or "quit", break the loop and exit the REPL. if input == "exit" || input == "quit" { fmt.Println("Goodbye!") break } + // Add the input to the history for future navigation. history = append(history, input) - // Ignore errors for now, gives better ux - output, _ := execute(input) - - fmt.Println(styles.CommandStyle.Render(output)) + output, _ := execute(input) // Ignore errors for now, gives better ux + fmt.Println(styles.CommandStyle.Render(output)) // Print the output of the command in a styled format. } return nil diff --git a/pkg/cli/repl/suggestions.go b/pkg/cli/repl/suggestions.go index cc8b5d6..e99c681 100644 --- a/pkg/cli/repl/suggestions.go +++ b/pkg/cli/repl/suggestions.go @@ -9,6 +9,7 @@ import ( "github.com/muesli/reflow/truncate" ) +// rootSuggestions is a list of prompt suggestions for root-level commands. var rootSuggestions = []prompt.Suggest{ {Text: "pm", Description: "Project Management System"}, {Text: "exit", Description: "Exit pm CLI"}, @@ -17,22 +18,27 @@ var rootSuggestions = []prompt.Suggest{ {Text: "git", Description: "Version control system"}, } +// commandSuggestions is a list of prompt suggestions for PM commands. var baseSuggestions = []prompt.Suggest{ {Text: "help", Description: "Show help information"}, {Text: "delete", Description: "Delete an issue by ID"}, + {Text: "close", Description: "Close an issue by ID"}, {Text: "create", Description: "Create a new issue with title"}, {Text: "update", Description: "Update an existing issue by ID"}, {Text: "describe", Description: "Get issue details by ID"}, {Text: "list", Description: "List all issues"}, } +// createFlags is a list of prompt suggestions for the create command flags. var createFlags = []prompt.Suggest{ + {Text: "--interactive", Description: "Create issue interactively"}, {Text: "--desc", Description: "Issue description"}, {Text: "--status", Description: "Issue status (open, closed, in_progress)"}, {Text: "--type", Description: "Issue type (bug, feature, task)"}, {Text: "--priority", Description: "Issue priority (0-5)"}, } +// updateFlags is a list of prompt suggestions for the update command flags. var updateFlags = []prompt.Suggest{ {Text: "--title", Description: "New issue title"}, {Text: "--desc", Description: "New issue description"}, @@ -41,6 +47,7 @@ var updateFlags = []prompt.Suggest{ {Text: "--priority", Description: "New issue priority (0-5)"}, } +// listFlags is a list of prompt suggestions for the list command flags. var listFlags = []prompt.Suggest{ {Text: "--title", Description: "Filter by title"}, {Text: "--desc", Description: "Filter by description"}, @@ -50,27 +57,63 @@ var listFlags = []prompt.Suggest{ {Text: "--limit", Description: "Limit number of results"}, } +var deleteFlags = []prompt.Suggest{ + {Text: "--yes", Description: "Confirm deletion without prompt"}, + {Text: "--interactive", Description: "Select issues to delete interactively"}, +} + +// statusValues is a list of prompt suggestions for status types var statusValues = []prompt.Suggest{ {Text: "open", Description: "Open status"}, {Text: "closed", Description: "Closed status"}, {Text: "in_progress", Description: "In progress status"}, } +// typeValues is a list of prompt suggestions for issue types var typeValues = []prompt.Suggest{ {Text: "bug", Description: "Bug issue type"}, {Text: "feature", Description: "Feature issue type"}, {Text: "task", Description: "Task issue type"}, } +// priorityValues is a list of prompt suggestions for issue priority levels var priorityValues = []prompt.Suggest{ {Text: "0", Description: "Lowest priority"}, {Text: "1", Description: "Low priority"}, {Text: "2", Description: "Medium-low priority"}, {Text: "3", Description: "Medium priority"}, {Text: "4", Description: "High priority"}, - {Text: "5", Description: "Highest priority"}, } +// isIDCommand maps command names to a boolean indicating whether they expect an issue ID as an argument. +var isIDCommand = map[string]bool{ + "describe": true, + "delete": true, + "del": true, + "rm": true, + "remove": true, + "get": true, + "read": true, + "close": true, + "update": true, + "edit": true, +} + +var commandFlags = map[string][]prompt.Suggest{ + "create": createFlags, + "add": createFlags, + "update": updateFlags, + "edit": updateFlags, + "list": listFlags, + "ls": listFlags, + "search": listFlags, + "delete": deleteFlags, + "del": deleteFlags, + "rm": deleteFlags, + "remove": deleteFlags, +} + +// commandSuggestions returns a list of prompt suggestions based on the current input words. func commandSuggestions(words []string) []prompt.Suggest { if len(words) == 0 { return baseSuggestions @@ -78,6 +121,7 @@ func commandSuggestions(words []string) []prompt.Suggest { return filterByPrefix(baseSuggestions, words[0]) } +// flagSuggestions returns a list of prompt suggestions for command flags based on the current input. func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { lastWord, prevWord := parseWords(words, text) @@ -85,42 +129,19 @@ func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { return filterByPrefix(values, lastWord) } - if cmd == "describe" || cmd == "delete" || cmd == "del" || cmd == "rm" || cmd == "remove" || cmd == "get" || cmd == "read" { - // For ID-oriented commands, pass the current partial argument (lastWord) - // so issueIDSuggestions can distinguish between completing the command - // name and completing the ID itself. - return issueIDSuggestions(lastWord, len(words) >= 2) - } + flags := commandFlags[cmd] - // Update/edit: suggest issue IDs when typing the ID, flags after ID is provided - if cmd == "update" || cmd == "edit" { - if len(words) >= 2 && (lastWord == "" || strings.HasPrefix(lastWord, "-")) { - // ID already provided (trailing space) or typing a flag -> suggest update flags - flags := updateFlags - if lastWord == "" { - return flags - } - return filterByPrefix(flags, lastWord) + if isIDCommand[cmd] { + if len(words) < 2 && !strings.HasPrefix(lastWord, "-") { + return issueIDSuggestions(lastWord, true) } - return issueIDSuggestions(lastWord, len(words) >= 2) + return filterByPrefix(flags, lastWord) } - var flags []prompt.Suggest - switch cmd { - case "create", "add": - flags = createFlags - case "list", "ls", "search": - flags = listFlags - default: - return nil - } - - if lastWord == "" { - return flags - } return filterByPrefix(flags, lastWord) } +// issueIDSuggestions returns a list of prompt suggestions for issue IDs based on the current partial input. func issueIDSuggestions(partial string, hasCommand bool) []prompt.Suggest { // Only show suggestions if we've typed the command already if !hasCommand { @@ -139,6 +160,7 @@ func issueIDSuggestions(partial string, hasCommand bool) []prompt.Suggest { return suggestions } +// parseWords extracts the last and previous words from the input for flag suggestion logic. func parseWords(words []string, text string) (lastWord, prevWord string) { if len(words) > 0 && !strings.HasSuffix(text, " ") { lastWord = words[len(words)-1] @@ -151,6 +173,7 @@ func parseWords(words []string, text string) (lastWord, prevWord string) { return } +// getFlagValues returns a list of prompt suggestions for flag values based on the given flag. func getFlagValues(flag string) []prompt.Suggest { switch flag { case "-s", "--status": @@ -163,6 +186,7 @@ func getFlagValues(flag string) []prompt.Suggest { return nil } +// filterByPrefix filters a list of prompt suggestions based on a given prefix. func filterByPrefix(suggestions []prompt.Suggest, prefix string) []prompt.Suggest { if prefix == "" { return suggestions