refactor to decouple commands form entrypoint for reusability and centralizing commands.

Increase reusability and composition
This commit is contained in:
Robin Olsen
2026-02-22 19:46:55 +01:00
parent 118e67dbf4
commit 33c8c05ae3
28 changed files with 305 additions and 282 deletions

View File

@@ -0,0 +1,60 @@
package issuesCmd
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")
}
app := AppFromContext(cmd.Context())
// Fetch the issue to ensure it exists before closing.
issue, err := app.Issues.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 = app.Issues.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
}

View File

@@ -0,0 +1,57 @@
package issuesCmd
import (
"context"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
"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)
var ids []string
for _, issue := range issues {
ids = append(ids, issue.ID)
}
return ids, cobra.ShellCompDirectiveNoFileComp
}
// GetIssueCompletions fetches issues matching the toComplete string for shell completion.
func GetIssueCompletions(ctx context.Context, toComplete string) ([]models.Issue, cobra.ShellCompDirective) {
app := AppFromContext(ctx)
if app == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
issues, err := app.Issues.AllIssues(ctx)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var completions []models.Issue
for _, issue := range issues {
if strings.HasPrefix(issue.ID, toComplete) {
completions = append(completions, issue)
} else if strings.HasPrefix(issue.Title, toComplete) {
completions = append(completions, issue)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -0,0 +1,117 @@
package issuesCmd
import (
"fmt"
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
// 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
pm create Fix bug --desc "Bug description" --status in_progress --type bug --priority 5`
)
// CreateCmd represents the create command, which allows users to create a new issue with specified details.
var CreateCmd = &cobra.Command{
Use: "create [title]",
Short: "Create a new issue",
Long: `Create a new issue with the specified details.`,
Example: createCmdExample,
Args: cobra.MinimumNArgs(0),
Aliases: []string{"add"},
RunE: runCreateCmd,
}
// runCreateCmd executes the create command logic,
func runCreateCmd(cmd *cobra.Command, args []string) error {
createFlags.title = strings.Join(args, " ")
// 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: 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.
app := AppFromContext(cmd.Context())
err := app.Issues.CreateIssue(cmd.Context(), issue, "test_actor")
if err != nil {
return fmt.Errorf("error creating issue: %w", err)
}
// 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().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", completionFunc(typeOptions))
CreateCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions))
CreateCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange))
}

View File

@@ -0,0 +1,130 @@
package issuesCmd
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
deleteIDs []string
deleteInteractive bool
)
// DeleteCmd represents the delete command.
var DeleteCmd = &cobra.Command{
Use: "delete [id]",
Short: "Delete an existing issue",
Long: `Delete an existing issue by its ID.`,
Example: `pm delete pm-abc`,
ValidArgsFunction: completeIssues,
Aliases: []string{"del", "remove", "rm"},
RunE: runDeleteCmd,
}
// runDeleteCmd executes the delete command logic,
// which deletes an issue by its ID after confirming with the user.
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")
}
app := AppFromContext(cmd.Context())
// Fetch the issue to ensure it exists before deletion.
issue, err := app.Issues.GetIssue(cmd.Context(), deleteID)
if err != nil {
return fmt.Errorf("error fetching issue: %w", err)
}
if issue == nil {
return fmt.Errorf("issue with ID %s not found", deleteID)
}
// Prompt for confirmation if not already confirmed via flag.
if !cmd.Flags().Changed("yes") {
huh.NewConfirm().Value(&confirmDelete).
Title("You want to delete this issue?").
Inline(true).WithTheme(huh.ThemeBase()).Run()
}
// If user did not confirm, cancel deletion.
if !confirmDelete {
cmd.Println("Deletion cancelled.")
return nil
}
// Delete the issue.
err = app.Issues.DeleteIssue(cmd.Context(), deleteID)
if err != nil {
return fmt.Errorf("error deleting issue: %w", err)
}
cmd.Println("Deleted issue with ID:", deleteID)
return nil
}
// runDeleteInteractive runs the interactive mode for deleting issues,
// allowing users to select multiple issues for deletion.
func runDeleteInteractive(ctx context.Context) error {
app := AppFromContext(ctx)
options := []huh.Option[string]{}
issues, err := app.Issues.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 := app.Issues.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")
}

View File

@@ -0,0 +1,84 @@
package issuesCmd
import (
"strings"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/spf13/cobra"
)
// Variables for get-issues command flags.
var listFlags Flags
const (
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`
)
// ListCmd represents the get issues command.
var ListCmd = &cobra.Command{
Use: "list [search query]",
Short: "List all issues",
Long: `List all issues in the project management system.`,
Example: lsExamples,
Aliases: []string{"ls", "search"},
Args: cobra.MinimumNArgs(0),
RunE: runGetIssuesCmd,
}
// runGetIssuesCmd executes the get issues command logic,
// which retrieves and displays a list of issues based on the provided search query and filters.
func runGetIssuesCmd(cmd *cobra.Command, args []string) error {
queryArg := strings.Join(args, " ")
filter := models.IssueFilter{
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(listFlags.status)
filter.Status = &s
}
if cmd.Flags().Changed("type") {
t := models.IssueType(listFlags.issueType)
filter.IssueType = &t
}
if cmd.Flags().Changed("priority") {
filter.Priority = &listFlags.priority
}
// Fetch issues based on the search query and filters.
app := AppFromContext(cmd.Context())
issuesPtr, err := app.Issues.SearchIssues(cmd.Context(), queryArg, filter)
if err != nil {
return err
}
// Convert the returned issue pointers to issue values and print them.
issues := models.IssuesPtrToIssues(issuesPtr)
models.PrintIssues(issues)
return nil
}
// init function to set up the get issues command and its flags.
func init() {
ListCmd.Flags().StringVar(&listFlags.title, "title", "", "Filter issues by title")
ListCmd.Flags().StringVarP(&listFlags.description, "desc", "d", "", "Filter issues by description")
ListCmd.Flags().StringVarP(&listFlags.status, "status", "s", "", "Filter issues by status (open, closed, in_progress)")
ListCmd.Flags().StringVarP(&listFlags.issueType, "type", "t", "", "Filter issues by type (bug, feature, task)")
ListCmd.Flags().IntVarP(&listFlags.priority, "priority", "p", 0, "Filter issues by priority (0-4)")
ListCmd.Flags().IntVarP(&listFlags.limit, "limit", "l", 25, "Limit the number of issues returned")
ListCmd.RegisterFlagCompletionFunc("status", completionFunc(statusOptions))
ListCmd.RegisterFlagCompletionFunc("type", completionFunc(typeOptions))
ListCmd.RegisterFlagCompletionFunc("priority", completionFunc(priorityRange))
}

View File

@@ -0,0 +1,47 @@
package issuesCmd
import (
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/spf13/cobra"
)
// GetCmd represents the get issue command.
var GetCmd = &cobra.Command{
Use: "describe [issue ID]",
Short: "Get issue details",
Long: `Get issue details by ID`,
ValidArgsFunction: completeIssues,
Aliases: []string{"get", "read"},
Args: cobra.ExactArgs(1),
RunE: runGetCmd,
}
// runGetCmd executes the get issue command logic,
// which retrieves and displays issue details by its ID.
func runGetCmd(cmd *cobra.Command, args []string) error {
issueID := args[0]
// Fetch the issue details using the service layer.
app := AppFromContext(cmd.Context())
issuePtr, err := app.Issues.GetIssue(cmd.Context(), issueID)
if err != nil {
return err
}
// If the issue is not found, inform the user.
if issuePtr == nil {
cmd.Printf("Issue with ID %s not found\n", issueID)
return nil
}
// Display the issue details to the user.
cmd.Println(models.IssueString(*issuePtr))
return nil
}
// init function to set up the get issue command.
func init() {
}

View File

@@ -0,0 +1,93 @@
package issuesCmd
import (
"bytes"
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/charmbracelet/fang"
"github.com/spf13/cobra"
)
type contextKey string
const appKey contextKey = "app"
// app is a package-level variable used during command setup
var app *service.App
// 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",
Long: `Project Management CLI for managing issues and tasks.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Inject app into context for all commands
if app != nil {
cmd.SetContext(context.WithValue(cmd.Context(), appKey, app))
}
},
}
// SetApp sets the app variable for use in command execution.
// Must be called before executing any commands to ensure services are available.
func SetApp(application *service.App) {
app = application
RootCmd.Use = app.Config.RootCmd
}
// AppFromContext retrieves the App from the command context
func AppFromContext(ctx context.Context) *service.App {
if a, ok := ctx.Value(appKey).(*service.App); ok {
return a
}
// Fallback to package-level app (for testing or edge cases)
return app
}
// ExecuteArgs executes the command with the given arguments using the fang library.
func ExecuteArgs(args []string) error {
RootCmd.SetArgs(args)
return fang.Execute(context.Background(), RootCmd,
fang.WithColorSchemeFunc(fang.AnsiColorScheme))
}
// ExecuteArgsString executes the command with the given arguments and returns the output as a string.
// This is useful for testing command outputs and used in the REPL
func ExecuteArgsString(args []string) (string, error) {
return ExecuteArgsStringWithContext(context.Background(), args)
}
// ExecuteArgsStringWithContext executes the command with context and returns the output as a string.
func ExecuteArgsStringWithContext(ctx context.Context, args []string) (string, error) {
buf := new(bytes.Buffer)
RootCmd.SetOut(buf)
RootCmd.SetErr(buf)
RootCmd.SetArgs(args)
RootCmd.SetContext(ctx)
err := RootCmd.Execute()
return buf.String(), err
}
// init function to set up the command hierarchy and options.
func init() {
RootCmd.CompletionOptions.DisableDefaultCmd = false
RootCmd.AddGroup(&cobra.Group{ID: "help", Title: "Helping Commands"})
RootCmd.SetCompletionCommandGroupID("help")
RootCmd.SetHelpCommandGroupID("help")
}

View File

@@ -0,0 +1,100 @@
package issuesCmd
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/spf13/cobra"
)
var updateFlags Flags
var UpdateCmd = &cobra.Command{
Use: "update [issue ID]",
Short: "Update an existing issue",
Long: `Update an existing issue by its ID with the specified details.`,
Example: `pm update pm-001 --title "New title" -d "Description" -s in_progress --type task -p 3`,
RunE: runUpdateCmd,
Aliases: []string{"edit"},
Args: cobra.ExactArgs(1),
ValidArgsFunction: completeIssues,
}
func runUpdateCmd(cmd *cobra.Command, args []string) error {
issueID := args[0]
app := AppFromContext(cmd.Context())
issue, err := app.Issues.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)
}
updates, err := getUpdateValues(cmd)
if err != nil {
return fmt.Errorf("error getting update values: %w", err)
}
err = app.Issues.UpdateIssue(cmd.Context(), issueID, updates, "test_actor")
if err != nil {
return fmt.Errorf("error updating issue: %w", err)
}
updatedIssue, err := app.Issues.GetIssue(cmd.Context(), issueID)
if err != nil {
return fmt.Errorf("error getting updated issue: %w", err)
}
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))
}
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
}

View File

@@ -0,0 +1,17 @@
package surveyCmd
import (
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/spf13/cobra"
)
var ListCmd = &cobra.Command{
Use: "list",
Short: "List available tasks",
RunE: func(cmd *cobra.Command, args []string) error {
for i, name := range task.List() {
cmd.Printf("%d. %s\n", i+1, name)
}
return nil
},
}

View File

@@ -0,0 +1,29 @@
package surveyCmd
import (
"github.com/spf13/cobra"
)
var (
InterfaceType string
Task int
)
// RootCmd is the base command for the survey CLI.
var RootCmd = &cobra.Command{
Use: "survey",
Long: `Project Management Interface Survey
Thank you for participating in our survey!
We are gathering data on how users interact with different task management interfaces to better understand their preferences and compare their usability.
This survey will present you with a series of tasks to complete using various interfaces, including command-line, web-based, and terminal user interfaces.
Please answer the questions honestly and to the best of your ability.
Your responses will be kept confidential and used solely for research purposes.`}
func init() {
RootCmd.CompletionOptions.DisableDefaultCmd = true
StartCmd.Flags().StringVarP(&InterfaceType, "interface", "i", "tui", "Specify interface.")
StartCmd.Flags().IntVarP(&Task, "task", "t", 1, "Run task directly")
}

View File

@@ -0,0 +1,9 @@
package surveyCmd
import "github.com/spf13/cobra"
// StartCmd is the start command - RunE is set in cmd/survey/
var StartCmd = &cobra.Command{
Use: "start",
Short: "Start the user survey",
}

View File

@@ -0,0 +1,30 @@
package surveyCmd
import (
"github.com/LazyBachelor/LazyPM/internal/commands/issues"
"github.com/spf13/cobra"
)
// StatusCmd displays the current task validation status
var StatusCmd = &cobra.Command{
Use: "status",
Short: "Check task validation status",
Long: "Displays the current task validation status and feedback.",
RunE: runStatusCmd,
}
func runStatusCmd(cmd *cobra.Command, args []string) error {
app := issuesCmd.AppFromContext(cmd.Context())
if app == nil || app.CurrentFeedback == nil {
cmd.Println("No validation status available.")
return nil
}
if app.CurrentFeedback.Message == "" {
cmd.Println("No validation status available yet.")
return nil
}
cmd.Print(app.CurrentFeedback.Message)
return nil
}

View File

@@ -0,0 +1,12 @@
package surveyCmd
import "github.com/spf13/cobra"
var SubmitCmd = &cobra.Command{
Use: "submit",
Short: "Submit your survey responses",
RunE: func(cmd *cobra.Command, args []string) error {
cmd.Println("Submitting responses and metrics...")
return nil
},
}