From 13d54d9e81e9f07b24e61eb347c2611a26f9af23 Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 25 Mar 2026 14:27:38 +0100 Subject: [PATCH 1/3] implementing dependency management in the cli --- internal/commands/issues/dependencies.go | 125 +++++++++++++++++++++++ pkg/repl/executor.go | 6 ++ pkg/repl/suggestions.go | 77 ++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 internal/commands/issues/dependencies.go diff --git a/internal/commands/issues/dependencies.go b/internal/commands/issues/dependencies.go new file mode 100644 index 0000000..57fbe3b --- /dev/null +++ b/internal/commands/issues/dependencies.go @@ -0,0 +1,125 @@ +package issues + +import ( + "fmt" + + "github.com/LazyBachelor/LazyPM/internal/models" + "github.com/spf13/cobra" +) + +const depCmdExample = `pm dep ISSUE-1 +pm dep view ISSUE-1 +pm dep add ISSUE-1 ISSUE-2 +pm dep remove ISSUE-1 ISSUE-2` + +// DepCmd manages dependencies for issues. +// Default behavior: view dependencies of an issue. +var DepCmd = &cobra.Command{ + Use: "dep [subcommand] [issue id]", + Short: "Manage dependencies for an issue", + Long: `Manage dependencies between issues.`, + Example: depCmdExample, + + // This command runs only when no subcommand is provided. + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeIssues, + RunE: runDepViewCmd, +} + +var DepViewCmd = &cobra.Command{ + Use: "view [issue id]", + Short: "View dependencies of an issue", + Aliases: []string{"list", "show"}, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completeIssues, + RunE: runDepViewCmd, +} + +var DepAddCmd = &cobra.Command{ + Use: "add ", + Short: "Add a dependency: issue depends on depends-on issue", + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeIssues, + RunE: runDepAddCmd, +} + +var DepRemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an existing dependency", + Aliases: []string{"rm"}, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completeIssues, + RunE: runDepRemoveCmd, +} + +func init() { + DepCmd.Aliases = []string{"dependencies"} + + DepCmd.AddCommand(DepViewCmd) + DepCmd.AddCommand(DepAddCmd) + DepCmd.AddCommand(DepRemoveCmd) +} + +func runDepViewCmd(cmd *cobra.Command, args []string) error { + issueID := args[0] + app := AppFromContext(cmd.Context()) + + deps, err := app.Issues.GetDependencies(cmd.Context(), issueID) + if err != nil { + return fmt.Errorf("failed to load dependencies for %s: %w", issueID, err) + } + + if len(deps) == 0 { + cmd.Printf("Issue %s has no dependencies.\n", issueID) + return nil + } + + cmd.Printf("Dependencies for %s:\n", issueID) + for _, d := range deps { + if d == nil { + continue + } + if d.Title != "" { + cmd.Printf("- %s: %s\n", d.ID, d.Title) + } else { + cmd.Printf("- %s\n", d.ID) + } + } + + return nil +} + +func runDepAddCmd(cmd *cobra.Command, args []string) error { + issueID := args[0] + dependsOnID := args[1] + + app := AppFromContext(cmd.Context()) + + dep := &models.Dependency{ + IssueID: issueID, + DependsOnID: dependsOnID, + Type: models.DepBlocks, + } + + if err := app.Issues.AddDependency(cmd.Context(), dep, "cli"); err != nil { + return fmt.Errorf("failed to add dependency: %s depends on %s: %w", issueID, dependsOnID, err) + } + + cmd.Printf("Added dependency: %s depends on %s\n", issueID, dependsOnID) + return runDepViewCmd(cmd, []string{issueID}) +} + +func runDepRemoveCmd(cmd *cobra.Command, args []string) error { + issueID := args[0] + dependsOnID := args[1] + + app := AppFromContext(cmd.Context()) + + if err := app.Issues.RemoveDependency(cmd.Context(), issueID, dependsOnID, "cli"); err != nil { + return fmt.Errorf("failed to remove dependency: %s depends on %s: %w", issueID, dependsOnID, err) + } + + cmd.Printf("Removed dependency: %s no longer depends on %s\n", issueID, dependsOnID) + return runDepViewCmd(cmd, []string{issueID}) +} + diff --git a/pkg/repl/executor.go b/pkg/repl/executor.go index d12a89c..6897396 100644 --- a/pkg/repl/executor.go +++ b/pkg/repl/executor.go @@ -28,6 +28,12 @@ func (r *REPL) execute(input string) (string, error) { if after, ok := strings.CutPrefix(input, "pm"); ok { if strings.Contains(strings.Split(after, " ")[1], "start") { + //trimmedAfter := strings.TrimSpace(after) + //parts := shellSplit(trimmedAfter) + + // Prevent accidental attempts to run "pm start ..." inside the REPL. + // (The REPL uses `status/title/help` and shell execution instead.) + //if len(parts) > 1 && strings.Contains(parts[1], "start") { return "Nice try👻", nil } diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 4715312..30d5a20 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -31,6 +31,7 @@ var baseSuggestions = []prompt.Suggest{ {Text: "delete", Description: "Delete an issue by ID"}, {Text: "comment", Description: "Add a comment on an issue by ID"}, {Text: "comments", Description: "List comments on an issue by ID"}, + {Text: "dep", Description: "Manage dependencies for an issue"}, {Text: "new", Description: "Alias for create command"}, {Text: "ls", Description: "Alias for list command"}, @@ -123,6 +124,15 @@ var sprintSubcommands = []prompt.Suggest{ {Text: "del", Description: "Alias for delete subcommand"}, } +var depSubcommands = []prompt.Suggest{ + {Text: "view", Description: "View dependencies of an issue"}, + {Text: "list", Description: "Alias for view"}, + {Text: "show", Description: "Alias for view"}, + {Text: "add", Description: "Add a dependency"}, + {Text: "remove", Description: "Remove a dependency"}, + {Text: "rm", Description: "Alias for remove"}, +} + // statusValues is a list of prompt suggestions for status types var statusValues = []prompt.Suggest{ {Text: "open", Description: "Open status"}, @@ -195,6 +205,73 @@ func commandSuggestions(words []string) []prompt.Suggest { func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { lastWord, prevWord := parseWords(words, text) + // Custom completion logic for `pm dep ...` + if cmd == "dep" { + switch len(words) { + case 1: + // `pm dep ` => next token could be a subcommand; default view is handled + // once the user types an issue id + if strings.HasSuffix(text, " ") { + return depSubcommands + } + return nil + + case 2: + // either: + // - pm dep (default view) + // - pm dep + if strings.HasSuffix(text, " ") { + sub := words[1] + switch sub { + case "view", "list", "show", "add", "remove", "rm": + // After a valid subcommand we expect the issue id next. + return issueIDSuggestions("", true) + default: + // Treat it as an issue id already; no more suggestions. + return nil + } + } + + // Typing the 2nd token: suggest subcommands and issue IDs that match. + suggests := make([]prompt.Suggest, 0, len(depSubcommands)) + suggests = append(suggests, filterByPrefix(depSubcommands, lastWord)...) + //suggests = append(suggests, issueIDSuggestions(lastWord, true)...) + return suggests + + case 3: + // If subcommand is `add/remove`, third token is the issue id. + // If subcommand is show/list, third token is the issue id. + sub := words[1] + switch sub { + case "add", "remove", "rm": + if strings.HasSuffix(text, " ") { + // `pm dep add ` => next token is dependsOnID. + return issueIDSuggestions("", true) + } + return issueIDSuggestions(lastWord, true) + default: + // show/list/show => completing the issue id. + if strings.HasSuffix(text, " ") { + return nil + } + return issueIDSuggestions(lastWord, true) + } + + case 4: + // add/remove/rm have a 4th token: dependsOnID. + sub := words[1] + if sub == "add" || sub == "remove" || sub == "rm" { + if strings.HasSuffix(text, " ") { + return nil + } + return issueIDSuggestions(lastWord, true) + } + return nil + } + + return nil + } + if values := getFlagValues(prevWord); values != nil { return filterByPrefix(values, lastWord) } From d983427ca9dd9a64a58a0dc01bf46da5f1402e65 Mon Sep 17 00:00:00 2001 From: viljarb Date: Wed, 25 Mar 2026 14:28:30 +0100 Subject: [PATCH 2/3] forgot to add cmd/pm/init.go --- cmd/pm/init.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/pm/init.go b/cmd/pm/init.go index 5a6fcfb..c851f8b 100644 --- a/cmd/pm/init.go +++ b/cmd/pm/init.go @@ -72,6 +72,7 @@ func init() { issues.UpdateCmd.GroupID = "issues" issues.CloseCmd.GroupID = "issues" issues.DeleteCmd.GroupID = "issues" + issues.DepCmd.GroupID = "issues" RootCmd.AddCommand(issues.CreateCmd) RootCmd.AddCommand(issues.GetCmd) @@ -79,6 +80,7 @@ func init() { RootCmd.AddCommand(issues.UpdateCmd) RootCmd.AddCommand(issues.CloseCmd) RootCmd.AddCommand(issues.DeleteCmd) + RootCmd.AddCommand(issues.DepCmd) RootCmd.AddGroup(&cobra.Group{ID: "comment", Title: "Comment Management"}) issues.CommentCmd.GroupID = "comment" From 9ad1c5535c21fd2a3c58104d85ad6bd8cf1728af Mon Sep 17 00:00:00 2001 From: Robin Olsen Date: Thu, 26 Mar 2026 22:59:11 +0100 Subject: [PATCH 3/3] fix --- internal/commands/issues/dependencies.go | 14 ++++++-------- pkg/repl/executor.go | 6 ------ pkg/repl/suggestions.go | 6 +++--- 3 files changed, 9 insertions(+), 17 deletions(-) diff --git a/internal/commands/issues/dependencies.go b/internal/commands/issues/dependencies.go index 57fbe3b..19d2520 100644 --- a/internal/commands/issues/dependencies.go +++ b/internal/commands/issues/dependencies.go @@ -15,9 +15,10 @@ pm dep remove ISSUE-1 ISSUE-2` // DepCmd manages dependencies for issues. // Default behavior: view dependencies of an issue. var DepCmd = &cobra.Command{ - Use: "dep [subcommand] [issue id]", + Use: "dep [issue id]", Short: "Manage dependencies for an issue", Long: `Manage dependencies between issues.`, + Aliases: []string{"dependencies"}, Example: depCmdExample, // This command runs only when no subcommand is provided. @@ -29,14 +30,14 @@ var DepCmd = &cobra.Command{ var DepViewCmd = &cobra.Command{ Use: "view [issue id]", Short: "View dependencies of an issue", - Aliases: []string{"list", "show"}, + Aliases: []string{"show"}, Args: cobra.ExactArgs(1), ValidArgsFunction: completeIssues, RunE: runDepViewCmd, } var DepAddCmd = &cobra.Command{ - Use: "add ", + Use: "add [issue id] [depends-on id]", Short: "Add a dependency: issue depends on depends-on issue", Args: cobra.ExactArgs(2), ValidArgsFunction: completeIssues, @@ -44,17 +45,15 @@ var DepAddCmd = &cobra.Command{ } var DepRemoveCmd = &cobra.Command{ - Use: "remove ", + Use: "remove [issue id] [depends-on id]", Short: "Remove an existing dependency", - Aliases: []string{"rm"}, + Aliases: []string{"rm"}, Args: cobra.ExactArgs(2), ValidArgsFunction: completeIssues, RunE: runDepRemoveCmd, } func init() { - DepCmd.Aliases = []string{"dependencies"} - DepCmd.AddCommand(DepViewCmd) DepCmd.AddCommand(DepAddCmd) DepCmd.AddCommand(DepRemoveCmd) @@ -122,4 +121,3 @@ func runDepRemoveCmd(cmd *cobra.Command, args []string) error { cmd.Printf("Removed dependency: %s no longer depends on %s\n", issueID, dependsOnID) return runDepViewCmd(cmd, []string{issueID}) } - diff --git a/pkg/repl/executor.go b/pkg/repl/executor.go index 6897396..d12a89c 100644 --- a/pkg/repl/executor.go +++ b/pkg/repl/executor.go @@ -28,12 +28,6 @@ func (r *REPL) execute(input string) (string, error) { if after, ok := strings.CutPrefix(input, "pm"); ok { if strings.Contains(strings.Split(after, " ")[1], "start") { - //trimmedAfter := strings.TrimSpace(after) - //parts := shellSplit(trimmedAfter) - - // Prevent accidental attempts to run "pm start ..." inside the REPL. - // (The REPL uses `status/title/help` and shell execution instead.) - //if len(parts) > 1 && strings.Contains(parts[1], "start") { return "Nice try👻", nil } diff --git a/pkg/repl/suggestions.go b/pkg/repl/suggestions.go index 30d5a20..d7f8c17 100644 --- a/pkg/repl/suggestions.go +++ b/pkg/repl/suggestions.go @@ -23,6 +23,7 @@ var rootSuggestions = []prompt.Suggest{ var baseSuggestions = []prompt.Suggest{ {Text: "help", Description: "Show help information"}, {Text: "sprint", Description: "Manage sprints"}, + {Text: "dep", Description: "Manage dependencies"}, {Text: "create", Description: "Create a new issue with title"}, {Text: "list", Description: "List all issues"}, {Text: "read", Description: "Read issue details by ID"}, @@ -31,7 +32,6 @@ var baseSuggestions = []prompt.Suggest{ {Text: "delete", Description: "Delete an issue by ID"}, {Text: "comment", Description: "Add a comment on an issue by ID"}, {Text: "comments", Description: "List comments on an issue by ID"}, - {Text: "dep", Description: "Manage dependencies for an issue"}, {Text: "new", Description: "Alias for create command"}, {Text: "ls", Description: "Alias for list command"}, @@ -42,6 +42,7 @@ var baseSuggestions = []prompt.Suggest{ {Text: "rm", Description: "Alias for delete command"}, {Text: "del", Description: "Alias for delete command"}, {Text: "get", Description: "Alias for read command"}, + {Text: "dependencies", Description: "Alias for dep"}, } // createFlags is a list of prompt suggestions for the create command flags. @@ -126,7 +127,6 @@ var sprintSubcommands = []prompt.Suggest{ var depSubcommands = []prompt.Suggest{ {Text: "view", Description: "View dependencies of an issue"}, - {Text: "list", Description: "Alias for view"}, {Text: "show", Description: "Alias for view"}, {Text: "add", Description: "Add a dependency"}, {Text: "remove", Description: "Remove a dependency"}, @@ -223,7 +223,7 @@ func flagSuggestions(cmd string, words []string, text string) []prompt.Suggest { if strings.HasSuffix(text, " ") { sub := words[1] switch sub { - case "view", "list", "show", "add", "remove", "rm": + case "view", "show", "add", "remove", "rm": // After a valid subcommand we expect the issue id next. return issueIDSuggestions("", true) default: