From acc9a6f3a6df2961c3ae44352216d915cb9b5315 Mon Sep 17 00:00:00 2001 From: Michael Muré Date: Sat, 10 Sep 2022 11:09:19 +0200 Subject: commands: reorg into different packages --- commands/add.go | 76 ---- commands/add_test.go | 32 -- commands/bridge.go | 42 -- commands/bridge/bridge.go | 43 ++ commands/bridge/bridge_auth.go | 67 +++ commands/bridge/bridge_auth_addtoken.go | 133 ++++++ commands/bridge/bridge_auth_rm.go | 41 ++ commands/bridge/bridge_auth_show.go | 60 +++ commands/bridge/bridge_new.go | 234 +++++++++++ commands/bridge/bridge_pull.go | 155 +++++++ commands/bridge/bridge_push.go | 99 +++++ commands/bridge/bridge_rm.go | 36 ++ commands/bridge_auth.go | 67 --- commands/bridge_auth_addtoken.go | 131 ------ commands/bridge_auth_rm.go | 39 -- commands/bridge_auth_show.go | 58 --- commands/bridge_configure.go | 232 ---------- commands/bridge_pull.go | 153 ------- commands/bridge_push.go | 97 ----- commands/bridge_rm.go | 34 -- commands/bug/bug.go | 468 +++++++++++++++++++++ commands/bug/bug_comment.go | 52 +++ commands/bug/bug_comment_add.go | 80 ++++ commands/bug/bug_comment_add_test.go | 18 + commands/bug/bug_comment_edit.go | 77 ++++ commands/bug/bug_comment_edit_test.go | 23 + commands/bug/bug_comment_test.go | 164 ++++++++ commands/bug/bug_deselect.go | 37 ++ commands/bug/bug_label.go | 43 ++ commands/bug/bug_label_new.go | 47 +++ commands/bug/bug_label_rm.go | 47 +++ commands/bug/bug_new.go | 77 ++++ commands/bug/bug_new_test.go | 21 + commands/bug/bug_rm.go | 46 ++ commands/bug/bug_rm_test.go | 19 + commands/bug/bug_select.go | 62 +++ commands/bug/bug_show.go | 329 +++++++++++++++ commands/bug/bug_status.go | 41 ++ commands/bug/bug_status_close.go | 39 ++ commands/bug/bug_status_open.go | 39 ++ commands/bug/bug_test.go | 103 +++++ commands/bug/bug_title.go | 40 ++ commands/bug/bug_title_edit.go | 76 ++++ commands/bug/select/select.go | 129 ++++++ commands/bug/select/select_test.go | 79 ++++ commands/bug/testdata/comment/add-0-golden.txt | 3 + commands/bug/testdata/comment/add-1-golden.txt | 6 + commands/bug/testdata/comment/edit-0-golden.txt | 3 + commands/bug/testdata/comment/edit-1-golden.txt | 6 + .../bug/testdata/comment/message-only-0-golden.txt | 3 + commands/bug/testenv/testenv.go | 63 +++ commands/cmdjson/json_common.go | 48 +++ commands/cmdtest/golden.go | 5 + commands/commands.go | 16 +- commands/comment.go | 50 --- commands/comment_add.go | 78 ---- commands/comment_add_test.go | 33 -- commands/comment_edit.go | 76 ---- commands/comment_edit_test.go | 21 - commands/comment_test.go | 160 ------- commands/completion/helper_completion.go | 343 +++++++++++++++ commands/deselect.go | 36 -- commands/env.go | 160 ------- commands/env_testing.go | 40 -- commands/execenv/env.go | 191 +++++++++ commands/execenv/env_testing.go | 48 +++ commands/golden_test.go | 5 - commands/helper_completion.go | 342 --------------- commands/input/input.go | 2 +- commands/json_common.go | 55 --- commands/label.go | 35 +- commands/label_add.go | 45 -- commands/label_ls.go | 33 -- commands/label_rm.go | 45 -- commands/ls-id.go | 42 -- commands/ls-labels.go | 29 -- commands/ls.go | 446 -------------------- commands/ls_test.go | 101 ----- commands/pull.go | 28 +- commands/push.go | 19 +- commands/rm.go | 43 -- commands/rm_test.go | 17 - commands/root.go | 54 ++- commands/select.go | 60 --- commands/select/select.go | 129 ------ commands/select/select_test.go | 79 ---- commands/show.go | 326 -------------- commands/status.go | 38 -- commands/status_close.go | 35 -- commands/status_open.go | 35 -- commands/termui.go | 13 +- commands/testdata/comment/add-0-golden.txt | 3 - commands/testdata/comment/add-1-golden.txt | 6 - commands/testdata/comment/edit-0-golden.txt | 3 - commands/testdata/comment/edit-1-golden.txt | 6 - .../testdata/comment/message-only-0-golden.txt | 3 - commands/title.go | 37 -- commands/title_edit.go | 74 ---- commands/user.go | 110 ----- commands/user/user.go | 89 ++++ commands/user/user_adopt.go | 43 ++ commands/user/user_new.go | 98 +++++ commands/user/user_new_test.go | 14 + commands/user/user_show.go | 108 +++++ commands/user_adopt.go | 40 -- commands/user_create.go | 97 ----- commands/user_create_test.go | 38 -- commands/user_ls.go | 81 ---- commands/version.go | 20 +- commands/webui.go | 33 +- 110 files changed, 4212 insertions(+), 4121 deletions(-) delete mode 100644 commands/add.go delete mode 100644 commands/add_test.go delete mode 100644 commands/bridge.go create mode 100644 commands/bridge/bridge.go create mode 100644 commands/bridge/bridge_auth.go create mode 100644 commands/bridge/bridge_auth_addtoken.go create mode 100644 commands/bridge/bridge_auth_rm.go create mode 100644 commands/bridge/bridge_auth_show.go create mode 100644 commands/bridge/bridge_new.go create mode 100644 commands/bridge/bridge_pull.go create mode 100644 commands/bridge/bridge_push.go create mode 100644 commands/bridge/bridge_rm.go delete mode 100644 commands/bridge_auth.go delete mode 100644 commands/bridge_auth_addtoken.go delete mode 100644 commands/bridge_auth_rm.go delete mode 100644 commands/bridge_auth_show.go delete mode 100644 commands/bridge_configure.go delete mode 100644 commands/bridge_pull.go delete mode 100644 commands/bridge_push.go delete mode 100644 commands/bridge_rm.go create mode 100644 commands/bug/bug.go create mode 100644 commands/bug/bug_comment.go create mode 100644 commands/bug/bug_comment_add.go create mode 100644 commands/bug/bug_comment_add_test.go create mode 100644 commands/bug/bug_comment_edit.go create mode 100644 commands/bug/bug_comment_edit_test.go create mode 100644 commands/bug/bug_comment_test.go create mode 100644 commands/bug/bug_deselect.go create mode 100644 commands/bug/bug_label.go create mode 100644 commands/bug/bug_label_new.go create mode 100644 commands/bug/bug_label_rm.go create mode 100644 commands/bug/bug_new.go create mode 100644 commands/bug/bug_new_test.go create mode 100644 commands/bug/bug_rm.go create mode 100644 commands/bug/bug_rm_test.go create mode 100644 commands/bug/bug_select.go create mode 100644 commands/bug/bug_show.go create mode 100644 commands/bug/bug_status.go create mode 100644 commands/bug/bug_status_close.go create mode 100644 commands/bug/bug_status_open.go create mode 100644 commands/bug/bug_test.go create mode 100644 commands/bug/bug_title.go create mode 100644 commands/bug/bug_title_edit.go create mode 100644 commands/bug/select/select.go create mode 100644 commands/bug/select/select_test.go create mode 100644 commands/bug/testdata/comment/add-0-golden.txt create mode 100644 commands/bug/testdata/comment/add-1-golden.txt create mode 100644 commands/bug/testdata/comment/edit-0-golden.txt create mode 100644 commands/bug/testdata/comment/edit-1-golden.txt create mode 100644 commands/bug/testdata/comment/message-only-0-golden.txt create mode 100644 commands/bug/testenv/testenv.go create mode 100644 commands/cmdjson/json_common.go create mode 100644 commands/cmdtest/golden.go delete mode 100644 commands/comment.go delete mode 100644 commands/comment_add.go delete mode 100644 commands/comment_add_test.go delete mode 100644 commands/comment_edit.go delete mode 100644 commands/comment_edit_test.go delete mode 100644 commands/comment_test.go create mode 100644 commands/completion/helper_completion.go delete mode 100644 commands/deselect.go delete mode 100644 commands/env.go delete mode 100644 commands/env_testing.go create mode 100644 commands/execenv/env.go create mode 100644 commands/execenv/env_testing.go delete mode 100644 commands/golden_test.go delete mode 100644 commands/helper_completion.go delete mode 100644 commands/json_common.go delete mode 100644 commands/label_add.go delete mode 100644 commands/label_ls.go delete mode 100644 commands/label_rm.go delete mode 100644 commands/ls-id.go delete mode 100644 commands/ls-labels.go delete mode 100644 commands/ls.go delete mode 100644 commands/ls_test.go delete mode 100644 commands/rm.go delete mode 100644 commands/rm_test.go delete mode 100644 commands/select.go delete mode 100644 commands/select/select.go delete mode 100644 commands/select/select_test.go delete mode 100644 commands/show.go delete mode 100644 commands/status.go delete mode 100644 commands/status_close.go delete mode 100644 commands/status_open.go delete mode 100644 commands/testdata/comment/add-0-golden.txt delete mode 100644 commands/testdata/comment/add-1-golden.txt delete mode 100644 commands/testdata/comment/edit-0-golden.txt delete mode 100644 commands/testdata/comment/edit-1-golden.txt delete mode 100644 commands/testdata/comment/message-only-0-golden.txt delete mode 100644 commands/title.go delete mode 100644 commands/title_edit.go delete mode 100644 commands/user.go create mode 100644 commands/user/user.go create mode 100644 commands/user/user_adopt.go create mode 100644 commands/user/user_new.go create mode 100644 commands/user/user_new_test.go create mode 100644 commands/user/user_show.go delete mode 100644 commands/user_adopt.go delete mode 100644 commands/user_create.go delete mode 100644 commands/user_create_test.go delete mode 100644 commands/user_ls.go (limited to 'commands') diff --git a/commands/add.go b/commands/add.go deleted file mode 100644 index b43eda36..00000000 --- a/commands/add.go +++ /dev/null @@ -1,76 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/input" - "github.com/MichaelMure/git-bug/util/text" -) - -type addOptions struct { - title string - message string - messageFile string - nonInteractive bool -} - -func newAddCommand() *cobra.Command { - env := newEnv() - options := addOptions{} - - cmd := &cobra.Command{ - Use: "add", - Short: "Create a new bug.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runAdd(env, options) - }), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.title, "title", "t", "", - "Provide a title to describe the issue") - flags.StringVarP(&options.message, "message", "m", "", - "Provide a message to describe the issue") - flags.StringVarP(&options.messageFile, "file", "F", "", - "Take the message from the given file. Use - to read the message from the standard input") - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runAdd(env *Env, opts addOptions) error { - var err error - if opts.messageFile != "" && opts.message == "" { - opts.title, opts.message, err = input.BugCreateFileInput(opts.messageFile) - if err != nil { - return err - } - } - - if !opts.nonInteractive && opts.messageFile == "" && (opts.message == "" || opts.title == "") { - opts.title, opts.message, err = input.BugCreateEditorInput(env.backend, opts.title, opts.message) - - if err == input.ErrEmptyTitle { - env.out.Println("Empty title, aborting.") - return nil - } - if err != nil { - return err - } - } - - b, _, err := env.backend.NewBug( - text.CleanupOneLine(opts.title), - text.Cleanup(opts.message), - ) - if err != nil { - return err - } - - env.out.Printf("%s created\n", b.Id().Human()) - - return nil -} diff --git a/commands/add_test.go b/commands/add_test.go deleted file mode 100644 index 077995a6..00000000 --- a/commands/add_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package commands - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func newTestEnvAndBug(t *testing.T) (*testEnv, string) { - t.Helper() - - testEnv, _ := newTestEnvAndUser(t) - opts := addOptions{ - title: "this is a bug title", - message: "this is a bug message", - messageFile: "", - nonInteractive: true, - } - - require.NoError(t, runAdd(testEnv.env, opts)) - require.Regexp(t, "^[0-9A-Fa-f]{7} created\n$", testEnv.out) - bugID := strings.Split(testEnv.out.String(), " ")[0] - testEnv.out.Reset() - - return testEnv, bugID -} - -func TestAdd(t *testing.T) { - _, bugID := newTestEnvAndBug(t) - require.Regexp(t, "^[0-9A-Fa-f]{7}$", bugID) -} diff --git a/commands/bridge.go b/commands/bridge.go deleted file mode 100644 index 8ce35aa3..00000000 --- a/commands/bridge.go +++ /dev/null @@ -1,42 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" -) - -func newBridgeCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "bridge", - Short: "Configure and use bridges to other bug trackers.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridge(env) - }), - Args: cobra.NoArgs, - } - - cmd.AddCommand(newBridgeAuthCommand()) - cmd.AddCommand(newBridgeConfigureCommand()) - cmd.AddCommand(newBridgePullCommand()) - cmd.AddCommand(newBridgePushCommand()) - cmd.AddCommand(newBridgeRm()) - - return cmd -} - -func runBridge(env *Env) error { - configured, err := bridge.ConfiguredBridges(env.backend) - if err != nil { - return err - } - - for _, c := range configured { - env.out.Println(c) - } - - return nil -} diff --git a/commands/bridge/bridge.go b/commands/bridge/bridge.go new file mode 100644 index 00000000..980a38e2 --- /dev/null +++ b/commands/bridge/bridge.go @@ -0,0 +1,43 @@ +package bridgecmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func NewBridgeCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "bridge", + Short: "List bridges to other bug trackers", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridge(env) + }), + Args: cobra.NoArgs, + } + + cmd.AddCommand(newBridgeAuthCommand()) + cmd.AddCommand(newBridgeNewCommand()) + cmd.AddCommand(newBridgePullCommand()) + cmd.AddCommand(newBridgePushCommand()) + cmd.AddCommand(newBridgeRm()) + + return cmd +} + +func runBridge(env *execenv.Env) error { + configured, err := bridge.ConfiguredBridges(env.Backend) + if err != nil { + return err + } + + for _, c := range configured { + env.Out.Println(c) + } + + return nil +} diff --git a/commands/bridge/bridge_auth.go b/commands/bridge/bridge_auth.go new file mode 100644 index 00000000..52e063e6 --- /dev/null +++ b/commands/bridge/bridge_auth.go @@ -0,0 +1,67 @@ +package bridgecmd + +import ( + "sort" + "strings" + + text "github.com/MichaelMure/go-term-text" + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/colors" +) + +func newBridgeAuthCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "auth", + Short: "List all known bridge authentication credentials", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgeAuth(env) + }), + Args: cobra.NoArgs, + } + + cmd.AddCommand(newBridgeAuthAddTokenCommand()) + cmd.AddCommand(newBridgeAuthRm()) + cmd.AddCommand(newBridgeAuthShow()) + + return cmd +} + +func runBridgeAuth(env *execenv.Env) error { + creds, err := auth.List(env.Backend) + if err != nil { + return err + } + + for _, cred := range creds { + targetFmt := text.LeftPadMaxLine(cred.Target(), 10, 0) + + var value string + switch cred := cred.(type) { + case *auth.Token: + value = cred.Value + } + + meta := make([]string, 0, len(cred.Metadata())) + for k, v := range cred.Metadata() { + meta = append(meta, k+":"+v) + } + sort.Strings(meta) + metaFmt := strings.Join(meta, ",") + + env.Out.Printf("%s %s %s %s %s\n", + colors.Cyan(cred.ID().Human()), + colors.Yellow(targetFmt), + colors.Magenta(cred.Kind()), + value, + metaFmt, + ) + } + + return nil +} diff --git a/commands/bridge/bridge_auth_addtoken.go b/commands/bridge/bridge_auth_addtoken.go new file mode 100644 index 00000000..bcab7fc3 --- /dev/null +++ b/commands/bridge/bridge_auth_addtoken.go @@ -0,0 +1,133 @@ +package bridgecmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/mattn/go-isatty" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +type bridgeAuthAddTokenOptions struct { + target string + login string + user string +} + +func newBridgeAuthAddTokenCommand() *cobra.Command { + env := execenv.NewEnv() + options := bridgeAuthAddTokenOptions{} + + cmd := &cobra.Command{ + Use: "add-token [TOKEN]", + Short: "Store a new token", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgeAuthAddToken(env, options, args) + }), + Args: cobra.MaximumNArgs(1), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.target, "target", "t", "", + fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) + cmd.RegisterFlagCompletionFunc("target", completion.From(bridge.Targets())) + flags.StringVarP(&options.login, + "login", "l", "", "The login in the remote bug-tracker") + flags.StringVarP(&options.user, + "user", "u", "", "The user to add the token to. Default is the current user") + cmd.RegisterFlagCompletionFunc("user", completion.User(env)) + + return cmd +} + +func runBridgeAuthAddToken(env *execenv.Env, opts bridgeAuthAddTokenOptions, args []string) error { + // Note: as bridgeAuthAddTokenLogin is not checked against the remote bug-tracker, + // it's possible to register a credential with an incorrect login (including bad case). + // The consequence is that it will not get picked later by the bridge. I find that + // checking it would require a cumbersome UX (need to provide a base URL for some bridges, ...) + // so it's probably not worth it, unless we refactor that entirely. + + if opts.target == "" { + return fmt.Errorf("flag --target is required") + } + if opts.login == "" { + return fmt.Errorf("flag --login is required") + } + + if !core.TargetExist(opts.target) { + return fmt.Errorf("unknown target") + } + + var value string + + if len(args) == 1 { + value = args[0] + } else { + // Read from Stdin + if isatty.IsTerminal(os.Stdin.Fd()) { + env.Err.Println("Enter the token:") + } + reader := bufio.NewReader(os.Stdin) + raw, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading from stdin: %v", err) + } + value = strings.TrimSuffix(raw, "\n") + } + + var user *cache.IdentityCache + var err error + + if opts.user == "" { + user, err = env.Backend.GetUserIdentity() + } else { + user, err = env.Backend.ResolveIdentityPrefix(opts.user) + } + if err != nil { + return err + } + + metaKey, _ := bridge.LoginMetaKey(opts.target) + login, ok := user.ImmutableMetadata()[metaKey] + + switch { + case ok && login == opts.login: + // nothing to do + case ok && login != opts.login: + return fmt.Errorf("this user is already tagged with a different %s login", opts.target) + default: + user.SetMetadata(metaKey, opts.login) + err = user.Commit() + if err != nil { + return err + } + } + + token := auth.NewToken(opts.target, value) + token.SetMetadata(auth.MetaKeyLogin, opts.login) + + if err := token.Validate(); err != nil { + return errors.Wrap(err, "invalid token") + } + + err = auth.Store(env.Repo, token) + if err != nil { + return err + } + + env.Out.Printf("token %s added\n", token.ID()) + return nil +} diff --git a/commands/bridge/bridge_auth_rm.go b/commands/bridge/bridge_auth_rm.go new file mode 100644 index 00000000..d58ca63e --- /dev/null +++ b/commands/bridge/bridge_auth_rm.go @@ -0,0 +1,41 @@ +package bridgecmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBridgeAuthRm() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "rm BRIDGE_ID", + Short: "Remove a credential", + PreRunE: execenv.LoadRepo(env), + RunE: func(cmd *cobra.Command, args []string) error { + return runBridgeAuthRm(env, args) + }, + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.BridgeAuth(env), + } + + return cmd +} + +func runBridgeAuthRm(env *execenv.Env, args []string) error { + cred, err := auth.LoadWithPrefix(env.Repo, args[0]) + if err != nil { + return err + } + + err = auth.Remove(env.Repo, cred.ID()) + if err != nil { + return err + } + + env.Out.Printf("credential %s removed\n", cred.ID()) + return nil +} diff --git a/commands/bridge/bridge_auth_show.go b/commands/bridge/bridge_auth_show.go new file mode 100644 index 00000000..d373273d --- /dev/null +++ b/commands/bridge/bridge_auth_show.go @@ -0,0 +1,60 @@ +package bridgecmd + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBridgeAuthShow() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "show", + Short: "Display an authentication credential", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgeAuthShow(env, args) + }), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.BridgeAuth(env), + } + + return cmd +} + +func runBridgeAuthShow(env *execenv.Env, args []string) error { + cred, err := auth.LoadWithPrefix(env.Repo, args[0]) + if err != nil { + return err + } + + env.Out.Printf("Id: %s\n", cred.ID()) + env.Out.Printf("Target: %s\n", cred.Target()) + env.Out.Printf("Kind: %s\n", cred.Kind()) + env.Out.Printf("Creation: %s\n", cred.CreateTime().Format(time.RFC822)) + + switch cred := cred.(type) { + case *auth.Token: + env.Out.Printf("Value: %s\n", cred.Value) + } + + env.Out.Println("Metadata:") + + meta := make([]string, 0, len(cred.Metadata())) + for key, value := range cred.Metadata() { + meta = append(meta, fmt.Sprintf(" %s --> %s\n", key, value)) + } + sort.Strings(meta) + + env.Out.Print(strings.Join(meta, "")) + + return nil +} diff --git a/commands/bridge/bridge_new.go b/commands/bridge/bridge_new.go new file mode 100644 index 00000000..4cfc903d --- /dev/null +++ b/commands/bridge/bridge_new.go @@ -0,0 +1,234 @@ +package bridgecmd + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/repository" +) + +type bridgeNewOptions struct { + name string + target string + params core.BridgeParams + token string + tokenStdin bool + nonInteractive bool +} + +func newBridgeNewCommand() *cobra.Command { + env := execenv.NewEnv() + options := bridgeNewOptions{} + + cmd := &cobra.Command{ + Use: "new", + Short: "Configure a new bridge", + Long: ` Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.`, + Example: `# Interactive example +[1]: github +[2]: gitlab +[3]: jira +[4]: launchpad-preview + +target: 1 +name [default]: default + +Detected projects: +[1]: github.com/a-hilaly/git-bug +[2]: github.com/MichaelMure/git-bug + +[0]: Another project + +Select option: 1 + +[1]: user provided token +[2]: interactive token creation +Select option: 1 + +You can generate a new token by visiting https://github.com/settings/tokens. +Choose 'Generate new token' and set the necessary access scope for your repository. + +The access scope depend on the type of repository. +Public: + - 'public_repo': to be able to read public repositories +Private: + - 'repo' : to be able to read private repositories + +Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700 +Successfully configured bridge: default + +# For GitHub +git bug bridge new \ + --name=default \ + --target=github \ + --owner=$(OWNER) \ + --project=$(PROJECT) \ + --token=$(TOKEN) + +# For Launchpad +git bug bridge new \ + --name=default \ + --target=launchpad-preview \ + --url=https://bugs.launchpad.net/ubuntu/ + +# For Gitlab +git bug bridge new \ + --name=default \ + --target=github \ + --url=https://github.com/michaelmure/git-bug \ + --token=$(TOKEN)`, + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgeNew(env, options) + }), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.name, "name", "n", "", "A distinctive name to identify the bridge") + flags.StringVarP(&options.target, "target", "t", "", + fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) + cmd.RegisterFlagCompletionFunc("target", completion.From(bridge.Targets())) + flags.StringVarP(&options.params.URL, "url", "u", "", "The URL of the remote repository") + flags.StringVarP(&options.params.BaseURL, "base-url", "b", "", "The base URL of your remote issue tracker") + flags.StringVarP(&options.params.Login, "login", "l", "", "The login on your remote issue tracker") + flags.StringVarP(&options.params.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for your remote issue tracker (see \"git-bug bridge auth\")") + flags.StringVar(&options.token, "token", "", "A raw authentication token for the remote issue tracker") + flags.BoolVar(&options.tokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token") + flags.StringVarP(&options.params.Owner, "owner", "o", "", "The owner of the remote repository") + flags.StringVarP(&options.params.Project, "project", "p", "", "The name of the remote repository") + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runBridgeNew(env *execenv.Env, opts bridgeNewOptions) error { + var err error + + if (opts.tokenStdin || opts.token != "" || opts.params.CredPrefix != "") && + (opts.name == "" || opts.target == "") { + return fmt.Errorf("you must provide a bridge name and target to configure a bridge with a credential") + } + + // early fail + if opts.params.CredPrefix != "" { + if _, err := auth.LoadWithPrefix(env.Repo, opts.params.CredPrefix); err != nil { + return err + } + } + + switch { + case opts.tokenStdin: + reader := bufio.NewReader(os.Stdin) + token, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading from stdin: %v", err) + } + opts.params.TokenRaw = strings.TrimSpace(token) + case opts.token != "": + opts.params.TokenRaw = opts.token + } + + if !opts.nonInteractive && opts.target == "" { + opts.target, err = promptTarget() + if err != nil { + return err + } + } + + if !opts.nonInteractive && opts.name == "" { + opts.name, err = promptName(env.Repo) + if err != nil { + return err + } + } + + b, err := bridge.NewBridge(env.Backend, opts.target, opts.name) + if err != nil { + return err + } + + err = b.Configure(opts.params, !opts.nonInteractive) + if err != nil { + return err + } + + env.Out.Printf("Successfully configured bridge: %s\n", opts.name) + return nil +} + +func promptTarget() (string, error) { + // TODO: use the reusable prompt from the input package + targets := bridge.Targets() + + for { + for i, target := range targets { + fmt.Printf("[%d]: %s\n", i+1, target) + } + fmt.Printf("target: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + + if err != nil { + return "", err + } + + line = strings.TrimSpace(line) + + index, err := strconv.Atoi(line) + if err != nil || index <= 0 || index > len(targets) { + fmt.Println("invalid input") + continue + } + + return targets[index-1], nil + } +} + +func promptName(repo repository.RepoConfig) (string, error) { + // TODO: use the reusable prompt from the input package + const defaultName = "default" + + defaultExist := core.BridgeExist(repo, defaultName) + + for { + if defaultExist { + fmt.Printf("name: ") + } else { + fmt.Printf("name [%s]: ", defaultName) + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimSpace(line) + + name := line + if defaultExist && name == "" { + continue + } + + if name == "" { + name = defaultName + } + + if !core.BridgeExist(repo, name) { + return name, nil + } + + fmt.Println("a bridge with the same name already exist") + } +} diff --git a/commands/bridge/bridge_pull.go b/commands/bridge/bridge_pull.go new file mode 100644 index 00000000..d1fc279a --- /dev/null +++ b/commands/bridge/bridge_pull.go @@ -0,0 +1,155 @@ +package bridgecmd + +import ( + "context" + "fmt" + "os" + "sync" + "time" + + "github.com/araddon/dateparse" + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +type bridgePullOptions struct { + importSince string + noResume bool +} + +func newBridgePullCommand() *cobra.Command { + env := execenv.NewEnv() + options := bridgePullOptions{} + + cmd := &cobra.Command{ + Use: "pull [NAME]", + Short: "Pull updates from a remote bug tracker", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgePull(env, options, args) + }), + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completion.Bridge(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.BoolVarP(&options.noResume, "no-resume", "n", false, "force importing all bugs") + flags.StringVarP(&options.importSince, "since", "s", "", "import only bugs updated after the given date (ex: \"200h\" or \"june 2 2019\")") + + return cmd +} + +func runBridgePull(env *execenv.Env, opts bridgePullOptions, args []string) error { + if opts.noResume && opts.importSince != "" { + return fmt.Errorf("only one of --no-resume and --since flags should be used") + } + + var b *core.Bridge + var err error + + if len(args) == 0 { + b, err = bridge.DefaultBridge(env.Backend) + } else { + b, err = bridge.LoadBridge(env.Backend, args[0]) + } + + if err != nil { + return err + } + + parentCtx := context.Background() + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + // buffered channel to avoid send block at the end + done := make(chan struct{}, 1) + + var mu sync.Mutex + interruptCount := 0 + interrupt.RegisterCleaner(func() error { + mu.Lock() + if interruptCount > 0 { + env.Err.Println("Received another interrupt before graceful stop, terminating...") + os.Exit(0) + } + + interruptCount++ + mu.Unlock() + + env.Err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)") + + // send signal to stop the importer + cancel() + + // block until importer gracefully shutdown + <-done + return nil + }) + + var events <-chan core.ImportResult + switch { + case opts.noResume: + events, err = b.ImportAllSince(ctx, time.Time{}) + case opts.importSince != "": + since, err2 := parseSince(opts.importSince) + if err2 != nil { + return errors.Wrap(err2, "import time parsing") + } + events, err = b.ImportAllSince(ctx, since) + default: + events, err = b.ImportAll(ctx) + } + + if err != nil { + return err + } + + importedIssues := 0 + importedIdentities := 0 + for result := range events { + switch result.Event { + case core.ImportEventNothing: + // filtered + + case core.ImportEventBug: + importedIssues++ + env.Out.Println(result.String()) + + case core.ImportEventIdentity: + importedIdentities++ + env.Out.Println(result.String()) + + case core.ImportEventError: + if result.Err != context.Canceled { + env.Out.Println(result.String()) + } + + default: + env.Out.Println(result.String()) + } + } + + env.Out.Printf("imported %d issues and %d identities with %s bridge\n", importedIssues, importedIdentities, b.Name) + + // send done signal + close(done) + + return nil +} + +func parseSince(since string) (time.Time, error) { + duration, err := time.ParseDuration(since) + if err == nil { + return time.Now().Add(-duration), nil + } + + return dateparse.ParseLocal(since) +} diff --git a/commands/bridge/bridge_push.go b/commands/bridge/bridge_push.go new file mode 100644 index 00000000..51baed4d --- /dev/null +++ b/commands/bridge/bridge_push.go @@ -0,0 +1,99 @@ +package bridgecmd + +import ( + "context" + "os" + "sync" + "time" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +func newBridgePushCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "push [NAME]", + Short: "Push updates to remote bug tracker", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgePush(env, args) + }), + Args: cobra.MaximumNArgs(1), + ValidArgsFunction: completion.Bridge(env), + } + + return cmd +} + +func runBridgePush(env *execenv.Env, args []string) error { + var b *core.Bridge + var err error + + if len(args) == 0 { + b, err = bridge.DefaultBridge(env.Backend) + } else { + b, err = bridge.LoadBridge(env.Backend, args[0]) + } + + if err != nil { + return err + } + + parentCtx := context.Background() + ctx, cancel := context.WithCancel(parentCtx) + defer cancel() + + done := make(chan struct{}, 1) + + var mu sync.Mutex + interruptCount := 0 + interrupt.RegisterCleaner(func() error { + mu.Lock() + if interruptCount > 0 { + env.Err.Println("Received another interrupt before graceful stop, terminating...") + os.Exit(0) + } + + interruptCount++ + mu.Unlock() + + env.Err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)") + + // send signal to stop the importer + cancel() + + // block until importer gracefully shutdown + <-done + return nil + }) + + events, err := b.ExportAll(ctx, time.Time{}) + if err != nil { + return err + } + + exportedIssues := 0 + for result := range events { + if result.Event != core.ExportEventNothing { + env.Out.Println(result.String()) + } + + switch result.Event { + case core.ExportEventBug: + exportedIssues++ + } + } + + env.Out.Printf("exported %d issues with %s bridge\n", exportedIssues, b.Name) + + // send done signal + close(done) + return nil +} diff --git a/commands/bridge/bridge_rm.go b/commands/bridge/bridge_rm.go new file mode 100644 index 00000000..5d8d23c5 --- /dev/null +++ b/commands/bridge/bridge_rm.go @@ -0,0 +1,36 @@ +package bridgecmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBridgeRm() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "rm NAME", + Short: "Delete a configured bridge", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBridgeRm(env, args) + }), + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.Bridge(env), + } + + return cmd +} + +func runBridgeRm(env *execenv.Env, args []string) error { + err := bridge.RemoveBridge(env.Backend, args[0]) + if err != nil { + return err + } + + env.Out.Printf("Successfully removed bridge configuration %v\n", args[0]) + return nil +} diff --git a/commands/bridge_auth.go b/commands/bridge_auth.go deleted file mode 100644 index 50306b8d..00000000 --- a/commands/bridge_auth.go +++ /dev/null @@ -1,67 +0,0 @@ -package commands - -import ( - "sort" - "strings" - - "github.com/spf13/cobra" - - text "github.com/MichaelMure/go-term-text" - - "github.com/MichaelMure/git-bug/bridge/core/auth" - "github.com/MichaelMure/git-bug/util/colors" -) - -func newBridgeAuthCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "auth", - Short: "List all known bridge authentication credentials.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgeAuth(env) - }), - Args: cobra.NoArgs, - } - - cmd.AddCommand(newBridgeAuthAddTokenCommand()) - cmd.AddCommand(newBridgeAuthRm()) - cmd.AddCommand(newBridgeAuthShow()) - - return cmd -} - -func runBridgeAuth(env *Env) error { - creds, err := auth.List(env.backend) - if err != nil { - return err - } - - for _, cred := range creds { - targetFmt := text.LeftPadMaxLine(cred.Target(), 10, 0) - - var value string - switch cred := cred.(type) { - case *auth.Token: - value = cred.Value - } - - meta := make([]string, 0, len(cred.Metadata())) - for k, v := range cred.Metadata() { - meta = append(meta, k+":"+v) - } - sort.Strings(meta) - metaFmt := strings.Join(meta, ",") - - env.out.Printf("%s %s %s %s %s\n", - colors.Cyan(cred.ID().Human()), - colors.Yellow(targetFmt), - colors.Magenta(cred.Kind()), - value, - metaFmt, - ) - } - - return nil -} diff --git a/commands/bridge_auth_addtoken.go b/commands/bridge_auth_addtoken.go deleted file mode 100644 index dfdc66b6..00000000 --- a/commands/bridge_auth_addtoken.go +++ /dev/null @@ -1,131 +0,0 @@ -package commands - -import ( - "bufio" - "fmt" - "os" - "strings" - - "github.com/mattn/go-isatty" - "github.com/pkg/errors" - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" - "github.com/MichaelMure/git-bug/bridge/core" - "github.com/MichaelMure/git-bug/bridge/core/auth" - "github.com/MichaelMure/git-bug/cache" -) - -type bridgeAuthAddTokenOptions struct { - target string - login string - user string -} - -func newBridgeAuthAddTokenCommand() *cobra.Command { - env := newEnv() - options := bridgeAuthAddTokenOptions{} - - cmd := &cobra.Command{ - Use: "add-token [TOKEN]", - Short: "Store a new token", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgeAuthAddToken(env, options, args) - }), - Args: cobra.MaximumNArgs(1), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.target, "target", "t", "", - fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) - cmd.RegisterFlagCompletionFunc("target", completeFrom(bridge.Targets())) - flags.StringVarP(&options.login, - "login", "l", "", "The login in the remote bug-tracker") - flags.StringVarP(&options.user, - "user", "u", "", "The user to add the token to. Default is the current user") - cmd.RegisterFlagCompletionFunc("user", completeUser(env)) - - return cmd -} - -func runBridgeAuthAddToken(env *Env, opts bridgeAuthAddTokenOptions, args []string) error { - // Note: as bridgeAuthAddTokenLogin is not checked against the remote bug-tracker, - // it's possible to register a credential with an incorrect login (including bad case). - // The consequence is that it will not get picked later by the bridge. I find that - // checking it would require a cumbersome UX (need to provide a base URL for some bridges, ...) - // so it's probably not worth it, unless we refactor that entirely. - - if opts.target == "" { - return fmt.Errorf("flag --target is required") - } - if opts.login == "" { - return fmt.Errorf("flag --login is required") - } - - if !core.TargetExist(opts.target) { - return fmt.Errorf("unknown target") - } - - var value string - - if len(args) == 1 { - value = args[0] - } else { - // Read from Stdin - if isatty.IsTerminal(os.Stdin.Fd()) { - env.err.Println("Enter the token:") - } - reader := bufio.NewReader(os.Stdin) - raw, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("reading from stdin: %v", err) - } - value = strings.TrimSuffix(raw, "\n") - } - - var user *cache.IdentityCache - var err error - - if opts.user == "" { - user, err = env.backend.GetUserIdentity() - } else { - user, err = env.backend.ResolveIdentityPrefix(opts.user) - } - if err != nil { - return err - } - - metaKey, _ := bridge.LoginMetaKey(opts.target) - login, ok := user.ImmutableMetadata()[metaKey] - - switch { - case ok && login == opts.login: - // nothing to do - case ok && login != opts.login: - return fmt.Errorf("this user is already tagged with a different %s login", opts.target) - default: - user.SetMetadata(metaKey, opts.login) - err = user.Commit() - if err != nil { - return err - } - } - - token := auth.NewToken(opts.target, value) - token.SetMetadata(auth.MetaKeyLogin, opts.login) - - if err := token.Validate(); err != nil { - return errors.Wrap(err, "invalid token") - } - - err = auth.Store(env.repo, token) - if err != nil { - return err - } - - env.out.Printf("token %s added\n", token.ID()) - return nil -} diff --git a/commands/bridge_auth_rm.go b/commands/bridge_auth_rm.go deleted file mode 100644 index a28057de..00000000 --- a/commands/bridge_auth_rm.go +++ /dev/null @@ -1,39 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge/core/auth" -) - -func newBridgeAuthRm() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "rm ID", - Short: "Remove a credential.", - PreRunE: loadRepo(env), - RunE: func(cmd *cobra.Command, args []string) error { - return runBridgeAuthRm(env, args) - }, - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeBridgeAuth(env), - } - - return cmd -} - -func runBridgeAuthRm(env *Env, args []string) error { - cred, err := auth.LoadWithPrefix(env.repo, args[0]) - if err != nil { - return err - } - - err = auth.Remove(env.repo, cred.ID()) - if err != nil { - return err - } - - env.out.Printf("credential %s removed\n", cred.ID()) - return nil -} diff --git a/commands/bridge_auth_show.go b/commands/bridge_auth_show.go deleted file mode 100644 index 7233bb51..00000000 --- a/commands/bridge_auth_show.go +++ /dev/null @@ -1,58 +0,0 @@ -package commands - -import ( - "fmt" - "sort" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge/core/auth" -) - -func newBridgeAuthShow() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "show", - Short: "Display an authentication credential.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgeAuthShow(env, args) - }), - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeBridgeAuth(env), - } - - return cmd -} - -func runBridgeAuthShow(env *Env, args []string) error { - cred, err := auth.LoadWithPrefix(env.repo, args[0]) - if err != nil { - return err - } - - env.out.Printf("Id: %s\n", cred.ID()) - env.out.Printf("Target: %s\n", cred.Target()) - env.out.Printf("Kind: %s\n", cred.Kind()) - env.out.Printf("Creation: %s\n", cred.CreateTime().Format(time.RFC822)) - - switch cred := cred.(type) { - case *auth.Token: - env.out.Printf("Value: %s\n", cred.Value) - } - - env.out.Println("Metadata:") - - meta := make([]string, 0, len(cred.Metadata())) - for key, value := range cred.Metadata() { - meta = append(meta, fmt.Sprintf(" %s --> %s\n", key, value)) - } - sort.Strings(meta) - - env.out.Print(strings.Join(meta, "")) - - return nil -} diff --git a/commands/bridge_configure.go b/commands/bridge_configure.go deleted file mode 100644 index d5b40dfd..00000000 --- a/commands/bridge_configure.go +++ /dev/null @@ -1,232 +0,0 @@ -package commands - -import ( - "bufio" - "fmt" - "os" - "strconv" - "strings" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" - "github.com/MichaelMure/git-bug/bridge/core" - "github.com/MichaelMure/git-bug/bridge/core/auth" - "github.com/MichaelMure/git-bug/repository" -) - -type bridgeConfigureOptions struct { - name string - target string - params core.BridgeParams - token string - tokenStdin bool - nonInteractive bool -} - -func newBridgeConfigureCommand() *cobra.Command { - env := newEnv() - options := bridgeConfigureOptions{} - - cmd := &cobra.Command{ - Use: "configure", - Short: "Configure a new bridge.", - Long: ` Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.`, - Example: `# Interactive example -[1]: github -[2]: gitlab -[3]: jira -[4]: launchpad-preview - -target: 1 -name [default]: default - -Detected projects: -[1]: github.com/a-hilaly/git-bug -[2]: github.com/MichaelMure/git-bug - -[0]: Another project - -Select option: 1 - -[1]: user provided token -[2]: interactive token creation -Select option: 1 - -You can generate a new token by visiting https://github.com/settings/tokens. -Choose 'Generate new token' and set the necessary access scope for your repository. - -The access scope depend on the type of repository. -Public: - - 'public_repo': to be able to read public repositories -Private: - - 'repo' : to be able to read private repositories - -Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700 -Successfully configured bridge: default - -# For GitHub -git bug bridge configure \ - --name=default \ - --target=github \ - --owner=$(OWNER) \ - --project=$(PROJECT) \ - --token=$(TOKEN) - -# For Launchpad -git bug bridge configure \ - --name=default \ - --target=launchpad-preview \ - --url=https://bugs.launchpad.net/ubuntu/ - -# For Gitlab -git bug bridge configure \ - --name=default \ - --target=github \ - --url=https://github.com/michaelmure/git-bug \ - --token=$(TOKEN)`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgeConfigure(env, options) - }), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.name, "name", "n", "", "A distinctive name to identify the bridge") - flags.StringVarP(&options.target, "target", "t", "", - fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) - cmd.RegisterFlagCompletionFunc("target", completeFrom(bridge.Targets())) - flags.StringVarP(&options.params.URL, "url", "u", "", "The URL of the remote repository") - flags.StringVarP(&options.params.BaseURL, "base-url", "b", "", "The base URL of your remote issue tracker") - flags.StringVarP(&options.params.Login, "login", "l", "", "The login on your remote issue tracker") - flags.StringVarP(&options.params.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for your remote issue tracker (see \"git-bug bridge auth\")") - flags.StringVar(&options.token, "token", "", "A raw authentication token for the remote issue tracker") - flags.BoolVar(&options.tokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token") - flags.StringVarP(&options.params.Owner, "owner", "o", "", "The owner of the remote repository") - flags.StringVarP(&options.params.Project, "project", "p", "", "The name of the remote repository") - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runBridgeConfigure(env *Env, opts bridgeConfigureOptions) error { - var err error - - if (opts.tokenStdin || opts.token != "" || opts.params.CredPrefix != "") && - (opts.name == "" || opts.target == "") { - return fmt.Errorf("you must provide a bridge name and target to configure a bridge with a credential") - } - - // early fail - if opts.params.CredPrefix != "" { - if _, err := auth.LoadWithPrefix(env.repo, opts.params.CredPrefix); err != nil { - return err - } - } - - switch { - case opts.tokenStdin: - reader := bufio.NewReader(os.Stdin) - token, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("reading from stdin: %v", err) - } - opts.params.TokenRaw = strings.TrimSpace(token) - case opts.token != "": - opts.params.TokenRaw = opts.token - } - - if !opts.nonInteractive && opts.target == "" { - opts.target, err = promptTarget() - if err != nil { - return err - } - } - - if !opts.nonInteractive && opts.name == "" { - opts.name, err = promptName(env.repo) - if err != nil { - return err - } - } - - b, err := bridge.NewBridge(env.backend, opts.target, opts.name) - if err != nil { - return err - } - - err = b.Configure(opts.params, !opts.nonInteractive) - if err != nil { - return err - } - - env.out.Printf("Successfully configured bridge: %s\n", opts.name) - return nil -} - -func promptTarget() (string, error) { - // TODO: use the reusable prompt from the input package - targets := bridge.Targets() - - for { - for i, target := range targets { - fmt.Printf("[%d]: %s\n", i+1, target) - } - fmt.Printf("target: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - - if err != nil { - return "", err - } - - line = strings.TrimSpace(line) - - index, err := strconv.Atoi(line) - if err != nil || index <= 0 || index > len(targets) { - fmt.Println("invalid input") - continue - } - - return targets[index-1], nil - } -} - -func promptName(repo repository.RepoConfig) (string, error) { - // TODO: use the reusable prompt from the input package - const defaultName = "default" - - defaultExist := core.BridgeExist(repo, defaultName) - - for { - if defaultExist { - fmt.Printf("name: ") - } else { - fmt.Printf("name [%s]: ", defaultName) - } - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", err - } - - line = strings.TrimSpace(line) - - name := line - if defaultExist && name == "" { - continue - } - - if name == "" { - name = defaultName - } - - if !core.BridgeExist(repo, name) { - return name, nil - } - - fmt.Println("a bridge with the same name already exist") - } -} diff --git a/commands/bridge_pull.go b/commands/bridge_pull.go deleted file mode 100644 index 9370e088..00000000 --- a/commands/bridge_pull.go +++ /dev/null @@ -1,153 +0,0 @@ -package commands - -import ( - "context" - "fmt" - "os" - "sync" - "time" - - "github.com/araddon/dateparse" - "github.com/pkg/errors" - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" - "github.com/MichaelMure/git-bug/bridge/core" - "github.com/MichaelMure/git-bug/util/interrupt" -) - -type bridgePullOptions struct { - importSince string - noResume bool -} - -func newBridgePullCommand() *cobra.Command { - env := newEnv() - options := bridgePullOptions{} - - cmd := &cobra.Command{ - Use: "pull [NAME]", - Short: "Pull updates.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgePull(env, options, args) - }), - Args: cobra.MaximumNArgs(1), - ValidArgsFunction: completeBridge(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.BoolVarP(&options.noResume, "no-resume", "n", false, "force importing all bugs") - flags.StringVarP(&options.importSince, "since", "s", "", "import only bugs updated after the given date (ex: \"200h\" or \"june 2 2019\")") - - return cmd -} - -func runBridgePull(env *Env, opts bridgePullOptions, args []string) error { - if opts.noResume && opts.importSince != "" { - return fmt.Errorf("only one of --no-resume and --since flags should be used") - } - - var b *core.Bridge - var err error - - if len(args) == 0 { - b, err = bridge.DefaultBridge(env.backend) - } else { - b, err = bridge.LoadBridge(env.backend, args[0]) - } - - if err != nil { - return err - } - - parentCtx := context.Background() - ctx, cancel := context.WithCancel(parentCtx) - defer cancel() - - // buffered channel to avoid send block at the end - done := make(chan struct{}, 1) - - var mu sync.Mutex - interruptCount := 0 - interrupt.RegisterCleaner(func() error { - mu.Lock() - if interruptCount > 0 { - env.err.Println("Received another interrupt before graceful stop, terminating...") - os.Exit(0) - } - - interruptCount++ - mu.Unlock() - - env.err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)") - - // send signal to stop the importer - cancel() - - // block until importer gracefully shutdown - <-done - return nil - }) - - var events <-chan core.ImportResult - switch { - case opts.noResume: - events, err = b.ImportAllSince(ctx, time.Time{}) - case opts.importSince != "": - since, err2 := parseSince(opts.importSince) - if err2 != nil { - return errors.Wrap(err2, "import time parsing") - } - events, err = b.ImportAllSince(ctx, since) - default: - events, err = b.ImportAll(ctx) - } - - if err != nil { - return err - } - - importedIssues := 0 - importedIdentities := 0 - for result := range events { - switch result.Event { - case core.ImportEventNothing: - // filtered - - case core.ImportEventBug: - importedIssues++ - env.out.Println(result.String()) - - case core.ImportEventIdentity: - importedIdentities++ - env.out.Println(result.String()) - - case core.ImportEventError: - if result.Err != context.Canceled { - env.out.Println(result.String()) - } - - default: - env.out.Println(result.String()) - } - } - - env.out.Printf("imported %d issues and %d identities with %s bridge\n", importedIssues, importedIdentities, b.Name) - - // send done signal - close(done) - - return nil -} - -func parseSince(since string) (time.Time, error) { - duration, err := time.ParseDuration(since) - if err == nil { - return time.Now().Add(-duration), nil - } - - return dateparse.ParseLocal(since) -} diff --git a/commands/bridge_push.go b/commands/bridge_push.go deleted file mode 100644 index ef1f2d3e..00000000 --- a/commands/bridge_push.go +++ /dev/null @@ -1,97 +0,0 @@ -package commands - -import ( - "context" - "os" - "sync" - "time" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" - "github.com/MichaelMure/git-bug/bridge/core" - "github.com/MichaelMure/git-bug/util/interrupt" -) - -func newBridgePushCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "push [NAME]", - Short: "Push updates.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgePush(env, args) - }), - Args: cobra.MaximumNArgs(1), - ValidArgsFunction: completeBridge(env), - } - - return cmd -} - -func runBridgePush(env *Env, args []string) error { - var b *core.Bridge - var err error - - if len(args) == 0 { - b, err = bridge.DefaultBridge(env.backend) - } else { - b, err = bridge.LoadBridge(env.backend, args[0]) - } - - if err != nil { - return err - } - - parentCtx := context.Background() - ctx, cancel := context.WithCancel(parentCtx) - defer cancel() - - done := make(chan struct{}, 1) - - var mu sync.Mutex - interruptCount := 0 - interrupt.RegisterCleaner(func() error { - mu.Lock() - if interruptCount > 0 { - env.err.Println("Received another interrupt before graceful stop, terminating...") - os.Exit(0) - } - - interruptCount++ - mu.Unlock() - - env.err.Println("Received interrupt signal, stopping the import...\n(Hit ctrl-c again to kill the process.)") - - // send signal to stop the importer - cancel() - - // block until importer gracefully shutdown - <-done - return nil - }) - - events, err := b.ExportAll(ctx, time.Time{}) - if err != nil { - return err - } - - exportedIssues := 0 - for result := range events { - if result.Event != core.ExportEventNothing { - env.out.Println(result.String()) - } - - switch result.Event { - case core.ExportEventBug: - exportedIssues++ - } - } - - env.out.Printf("exported %d issues with %s bridge\n", exportedIssues, b.Name) - - // send done signal - close(done) - return nil -} diff --git a/commands/bridge_rm.go b/commands/bridge_rm.go deleted file mode 100644 index 0306944e..00000000 --- a/commands/bridge_rm.go +++ /dev/null @@ -1,34 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" -) - -func newBridgeRm() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "rm NAME", - Short: "Delete a configured bridge.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runBridgeRm(env, args) - }), - Args: cobra.ExactArgs(1), - ValidArgsFunction: completeBridge(env), - } - - return cmd -} - -func runBridgeRm(env *Env, args []string) error { - err := bridge.RemoveBridge(env.backend, args[0]) - if err != nil { - return err - } - - env.out.Printf("Successfully removed bridge configuration %v\n", args[0]) - return nil -} diff --git a/commands/bug/bug.go b/commands/bug/bug.go new file mode 100644 index 00000000..04bf8980 --- /dev/null +++ b/commands/bug/bug.go @@ -0,0 +1,468 @@ +package bugcmd + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + text "github.com/MichaelMure/go-term-text" + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/cmdjson" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/entities/bug" + "github.com/MichaelMure/git-bug/entities/common" + "github.com/MichaelMure/git-bug/query" + "github.com/MichaelMure/git-bug/util/colors" +) + +type bugOptions struct { + statusQuery []string + authorQuery []string + metadataQuery []string + participantQuery []string + actorQuery []string + labelQuery []string + titleQuery []string + noQuery []string + sortBy string + sortDirection string + outputFormat string +} + +func NewBugCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugOptions{} + + cmd := &cobra.Command{ + Use: "bug [QUERY]", + Short: "List bugs", + Long: `Display a summary of each bugs. + +You can pass an additional query to filter and order the list. This query can be expressed either with a simple query language, flags, a natural language full text search, or a combination of the aforementioned.`, + Example: `List open bugs sorted by last edition with a query: +git bug status:open sort:edit-desc + +List closed bugs sorted by creation with flags: +git bug --status closed --by creation + +Do a full text search of all bugs: +git bug "foo bar" baz + +Use queries, flags, and full text search: +git bug status:open --by creation "foo bar" baz +`, + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBug(env, options, args) + }), + ValidArgsFunction: completion.Ls(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringSliceVarP(&options.statusQuery, "status", "s", nil, + "Filter by status. Valid values are [open,closed]") + cmd.RegisterFlagCompletionFunc("status", completion.From([]string{"open", "closed"})) + flags.StringSliceVarP(&options.authorQuery, "author", "a", nil, + "Filter by author") + flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil, + "Filter by metadata. Example: github-url=URL") + cmd.RegisterFlagCompletionFunc("author", completion.UserForQuery(env)) + flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil, + "Filter by participant") + cmd.RegisterFlagCompletionFunc("participant", completion.UserForQuery(env)) + flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil, + "Filter by actor") + cmd.RegisterFlagCompletionFunc("actor", completion.UserForQuery(env)) + flags.StringSliceVarP(&options.labelQuery, "label", "l", nil, + "Filter by label") + cmd.RegisterFlagCompletionFunc("label", completion.Label(env)) + flags.StringSliceVarP(&options.titleQuery, "title", "t", nil, + "Filter by title") + flags.StringSliceVarP(&options.noQuery, "no", "n", nil, + "Filter by absence of something. Valid values are [label]") + cmd.RegisterFlagCompletionFunc("no", completion.Label(env)) + flags.StringVarP(&options.sortBy, "by", "b", "creation", + "Sort the results by a characteristic. Valid values are [id,creation,edit]") + cmd.RegisterFlagCompletionFunc("by", completion.From([]string{"id", "creation", "edit"})) + flags.StringVarP(&options.sortDirection, "direction", "d", "asc", + "Select the sorting direction. Valid values are [asc,desc]") + cmd.RegisterFlagCompletionFunc("direction", completion.From([]string{"asc", "desc"})) + flags.StringVarP(&options.outputFormat, "format", "f", "default", + "Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode]") + cmd.RegisterFlagCompletionFunc("format", + completion.From([]string{"default", "plain", "compact", "id", "json", "org-mode"})) + + const selectGroup = "select" + cmd.AddGroup(&cobra.Group{ID: selectGroup, Title: "Implicit selection"}) + + addCmdWithGroup := func(child *cobra.Command, groupID string) { + cmd.AddCommand(child) + child.GroupID = groupID + } + + addCmdWithGroup(newBugDeselectCommand(), selectGroup) + addCmdWithGroup(newBugSelectCommand(), selectGroup) + + cmd.AddCommand(newBugCommentCommand()) + cmd.AddCommand(newBugLabelCommand()) + cmd.AddCommand(newBugNewCommand()) + cmd.AddCommand(newBugRmCommand()) + cmd.AddCommand(newBugShowCommand()) + cmd.AddCommand(newBugStatusCommand()) + cmd.AddCommand(newBugTitleCommand()) + + return cmd +} + +func runBug(env *execenv.Env, opts bugOptions, args []string) error { + var q *query.Query + var err error + + if len(args) >= 1 { + // either the shell or cobra remove the quotes, we need them back for the query parsing + assembled := repairQuery(args) + + q, err = query.Parse(assembled) + if err != nil { + return err + } + } else { + q = query.NewQuery() + } + + err = completeQuery(q, opts) + if err != nil { + return err + } + + allIds, err := env.Backend.QueryBugs(q) + if err != nil { + return err + } + + bugExcerpt := make([]*cache.BugExcerpt, len(allIds)) + for i, id := range allIds { + b, err := env.Backend.ResolveBugExcerpt(id) + if err != nil { + return err + } + bugExcerpt[i] = b + } + + switch opts.outputFormat { + case "org-mode": + return bugsOrgmodeFormatter(env, bugExcerpt) + case "plain": + return bugsPlainFormatter(env, bugExcerpt) + case "json": + return bugsJsonFormatter(env, bugExcerpt) + case "compact": + return bugsCompactFormatter(env, bugExcerpt) + case "id": + return bugsIDFormatter(env, bugExcerpt) + case "default": + return bugsDefaultFormatter(env, bugExcerpt) + default: + return fmt.Errorf("unknown format %s", opts.outputFormat) + } +} + +func repairQuery(args []string) string { + for i, arg := range args { + split := strings.Split(arg, ":") + for j, s := range split { + if strings.Contains(s, " ") { + split[j] = fmt.Sprintf("\"%s\"", s) + } + } + args[i] = strings.Join(split, ":") + } + return strings.Join(args, " ") +} + +type JSONBugExcerpt struct { + Id string `json:"id"` + HumanId string `json:"human_id"` + CreateTime cmdjson.Time `json:"create_time"` + EditTime cmdjson.Time `json:"edit_time"` + + Status string `json:"status"` + Labels []bug.Label `json:"labels"` + Title string `json:"title"` + Actors []cmdjson.Identity `json:"actors"` + Participants []cmdjson.Identity `json:"participants"` + Author cmdjson.Identity `json:"author"` + + Comments int `json:"comments"` + Metadata map[string]string `json:"metadata"` +} + +func bugsJsonFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts)) + for i, b := range bugExcerpts { + jsonBug := JSONBugExcerpt{ + Id: b.Id.String(), + HumanId: b.Id.Human(), + CreateTime: cmdjson.NewTime(b.CreateTime(), b.CreateLamportTime), + EditTime: cmdjson.NewTime(b.EditTime(), b.EditLamportTime), + Status: b.Status.String(), + Labels: b.Labels, + Title: b.Title, + Comments: b.LenComments, + Metadata: b.CreateMetadata, + } + + author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId) + if err != nil { + return err + } + jsonBug.Author = cmdjson.NewIdentityFromExcerpt(author) + + jsonBug.Actors = make([]cmdjson.Identity, len(b.Actors)) + for i, element := range b.Actors { + actor, err := env.Backend.ResolveIdentityExcerpt(element) + if err != nil { + return err + } + jsonBug.Actors[i] = cmdjson.NewIdentityFromExcerpt(actor) + } + + jsonBug.Participants = make([]cmdjson.Identity, len(b.Participants)) + for i, element := range b.Participants { + participant, err := env.Backend.ResolveIdentityExcerpt(element) + if err != nil { + return err + } + jsonBug.Participants[i] = cmdjson.NewIdentityFromExcerpt(participant) + } + + jsonBugs[i] = jsonBug + } + jsonObject, _ := json.MarshalIndent(jsonBugs, "", " ") + env.Out.Printf("%s\n", jsonObject) + return nil +} + +func bugsCompactFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + for _, b := range bugExcerpts { + author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId) + if err != nil { + return err + } + + var labelsTxt strings.Builder + for _, l := range b.Labels { + lc256 := l.Color().Term256() + labelsTxt.WriteString(lc256.Escape()) + labelsTxt.WriteString("◼") + labelsTxt.WriteString(lc256.Unescape()) + } + + env.Out.Printf("%s %s %s %s %s\n", + colors.Cyan(b.Id.Human()), + colors.Yellow(b.Status), + text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0), + text.LeftPadMaxLine(labelsTxt.String(), 5, 0), + colors.Magenta(text.TruncateMax(author.DisplayName(), 15)), + ) + } + return nil +} + +func bugsIDFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + for _, b := range bugExcerpts { + env.Out.Println(b.Id.String()) + } + + return nil +} + +func bugsDefaultFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + for _, b := range bugExcerpts { + author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId) + if err != nil { + return err + } + + var labelsTxt strings.Builder + for _, l := range b.Labels { + lc256 := l.Color().Term256() + labelsTxt.WriteString(lc256.Escape()) + labelsTxt.WriteString(" ◼") + labelsTxt.WriteString(lc256.Unescape()) + } + + // truncate + pad if needed + labelsFmt := text.TruncateMax(labelsTxt.String(), 10) + titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0) + authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0) + + comments := fmt.Sprintf("%3d 💬", b.LenComments-1) + if b.LenComments-1 <= 0 { + comments = "" + } + if b.LenComments-1 > 999 { + comments = " ∞ 💬" + } + + env.Out.Printf("%s\t%s\t%s\t%s\t%s\n", + colors.Cyan(b.Id.Human()), + colors.Yellow(b.Status), + titleFmt+labelsFmt, + colors.Magenta(authorFmt), + comments, + ) + } + return nil +} + +func bugsPlainFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + for _, b := range bugExcerpts { + env.Out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title)) + } + return nil +} + +func bugsOrgmodeFormatter(env *execenv.Env, bugExcerpts []*cache.BugExcerpt) error { + // see https://orgmode.org/manual/Tags.html + orgTagRe := regexp.MustCompile("[^[:alpha:]_@]") + formatTag := func(l bug.Label) string { + return orgTagRe.ReplaceAllString(l.String(), "_") + } + + formatTime := func(time time.Time) string { + return time.Format("[2006-01-02 Mon 15:05]") + } + + env.Out.Println("#+TODO: OPEN | CLOSED") + + for _, b := range bugExcerpts { + status := strings.ToUpper(b.Status.String()) + + var title string + if link, ok := b.CreateMetadata["github-url"]; ok { + title = fmt.Sprintf("[[%s][%s]]", link, b.Title) + } else { + title = b.Title + } + + author, err := env.Backend.ResolveIdentityExcerpt(b.AuthorId) + if err != nil { + return err + } + + var labels strings.Builder + labels.WriteString(":") + for i, l := range b.Labels { + if i > 0 { + labels.WriteString(":") + } + labels.WriteString(formatTag(l)) + } + labels.WriteString(":") + + env.Out.Printf("* %-6s %s %s %s: %s %s\n", + status, + b.Id.Human(), + formatTime(b.CreateTime()), + author.DisplayName(), + title, + labels.String(), + ) + + env.Out.Printf("** Last Edited: %s\n", formatTime(b.EditTime())) + + env.Out.Printf("** Actors:\n") + for _, element := range b.Actors { + actor, err := env.Backend.ResolveIdentityExcerpt(element) + if err != nil { + return err + } + + env.Out.Printf(": %s %s\n", + actor.Id.Human(), + actor.DisplayName(), + ) + } + + env.Out.Printf("** Participants:\n") + for _, element := range b.Participants { + participant, err := env.Backend.ResolveIdentityExcerpt(element) + if err != nil { + return err + } + + env.Out.Printf(": %s %s\n", + participant.Id.Human(), + participant.DisplayName(), + ) + } + } + + return nil +} + +// Finish the command flags transformation into the query.Query +func completeQuery(q *query.Query, opts bugOptions) error { + for _, str := range opts.statusQuery { + status, err := common.StatusFromString(str) + if err != nil { + return err + } + q.Status = append(q.Status, status) + } + + q.Author = append(q.Author, opts.authorQuery...) + for _, str := range opts.metadataQuery { + tokens := strings.Split(str, "=") + if len(tokens) < 2 { + return fmt.Errorf("no \"=\" in key=value metadata markup") + } + var pair query.StringPair + pair.Key = tokens[0] + pair.Value = tokens[1] + q.Metadata = append(q.Metadata, pair) + } + q.Participant = append(q.Participant, opts.participantQuery...) + q.Actor = append(q.Actor, opts.actorQuery...) + q.Label = append(q.Label, opts.labelQuery...) + q.Title = append(q.Title, opts.titleQuery...) + + for _, no := range opts.noQuery { + switch no { + case "label": + q.NoLabel = true + default: + return fmt.Errorf("unknown \"no\" filter %s", no) + } + } + + switch opts.sortBy { + case "id": + q.OrderBy = query.OrderById + case "creation": + q.OrderBy = query.OrderByCreation + case "edit": + q.OrderBy = query.OrderByEdit + default: + return fmt.Errorf("unknown sort flag %s", opts.sortBy) + } + + switch opts.sortDirection { + case "asc": + q.OrderDirection = query.OrderAscending + case "desc": + q.OrderDirection = query.OrderDescending + default: + return fmt.Errorf("unknown sort direction %s", opts.sortDirection) + } + + return nil +} diff --git a/commands/bug/bug_comment.go b/commands/bug/bug_comment.go new file mode 100644 index 00000000..bc665f0d --- /dev/null +++ b/commands/bug/bug_comment.go @@ -0,0 +1,52 @@ +package bugcmd + +import ( + text "github.com/MichaelMure/go-term-text" + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/colors" +) + +func newBugCommentCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "comment [BUG_ID]", + Short: "List a bug's comments", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugComment(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + cmd.AddCommand(newBugCommentNewCommand()) + cmd.AddCommand(newBugCommentEditCommand()) + + return cmd +} + +func runBugComment(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + for i, comment := range snap.Comments { + if i != 0 { + env.Out.Println() + } + + env.Out.Printf("Author: %s\n", colors.Magenta(comment.Author.DisplayName())) + env.Out.Printf("Id: %s\n", colors.Cyan(comment.CombinedId().Human())) + env.Out.Printf("Date: %s\n\n", comment.FormatTime()) + env.Out.Println(text.LeftPadLines(comment.Message, 4)) + } + + return nil +} diff --git a/commands/bug/bug_comment_add.go b/commands/bug/bug_comment_add.go new file mode 100644 index 00000000..b676db3a --- /dev/null +++ b/commands/bug/bug_comment_add.go @@ -0,0 +1,80 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/commands/input" + "github.com/MichaelMure/git-bug/util/text" +) + +type bugCommentNewOptions struct { + messageFile string + message string + nonInteractive bool +} + +func newBugCommentNewCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugCommentNewOptions{} + + cmd := &cobra.Command{ + Use: "new [BUG_ID]", + Short: "Add a new comment to a bug", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugCommentNew(env, options, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.messageFile, "file", "F", "", + "Take the message from the given file. Use - to read the message from the standard input") + + flags.StringVarP(&options.message, "message", "m", "", + "Provide the new message from the command line") + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runBugCommentNew(env *execenv.Env, opts bugCommentNewOptions, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + if opts.messageFile != "" && opts.message == "" { + opts.message, err = input.BugCommentFileInput(opts.messageFile) + if err != nil { + return err + } + } + + if opts.messageFile == "" && opts.message == "" { + if opts.nonInteractive { + env.Err.Println("No message given. Use -m or -F option to specify a message. Aborting.") + return nil + } + opts.message, err = input.BugCommentEditorInput(env.Backend, "") + if err == input.ErrEmptyMessage { + env.Err.Println("Empty message, aborting.") + return nil + } + if err != nil { + return err + } + } + + _, _, err = b.AddComment(text.Cleanup(opts.message)) + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_comment_add_test.go b/commands/bug/bug_comment_add_test.go new file mode 100644 index 00000000..55e285f4 --- /dev/null +++ b/commands/bug/bug_comment_add_test.go @@ -0,0 +1,18 @@ +package bugcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func TestBugCommentNew(t *testing.T) { + const golden = "testdata/comment/add" + + env, bugID, _ := testenv.NewTestEnvAndBugWithComment(t) + + require.NoError(t, runBugComment(env, []string{bugID.String()})) + requireCommentsEqual(t, golden, env) +} diff --git a/commands/bug/bug_comment_edit.go b/commands/bug/bug_comment_edit.go new file mode 100644 index 00000000..8be7cb80 --- /dev/null +++ b/commands/bug/bug_comment_edit.go @@ -0,0 +1,77 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/commands/input" +) + +type bugCommentEditOptions struct { + messageFile string + message string + nonInteractive bool +} + +func newBugCommentEditCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugCommentEditOptions{} + + cmd := &cobra.Command{ + Use: "edit [COMMENT_ID]", + Short: "Edit an existing comment on a bug", + Args: cobra.ExactArgs(1), + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugCommentEdit(env, options, args) + }), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.messageFile, "file", "F", "", + "Take the message from the given file. Use - to read the message from the standard input") + + flags.StringVarP(&options.message, "message", "m", "", + "Provide the new message from the command line") + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runBugCommentEdit(env *execenv.Env, opts bugCommentEditOptions, args []string) error { + b, commentId, err := env.Backend.ResolveComment(args[0]) + if err != nil { + return err + } + + if opts.messageFile != "" && opts.message == "" { + opts.message, err = input.BugCommentFileInput(opts.messageFile) + if err != nil { + return err + } + } + + if opts.messageFile == "" && opts.message == "" { + if opts.nonInteractive { + env.Err.Println("No message given. Use -m or -F option to specify a message. Aborting.") + return nil + } + opts.message, err = input.BugCommentEditorInput(env.Backend, "") + if err == input.ErrEmptyMessage { + env.Err.Println("Empty message, aborting.") + return nil + } + if err != nil { + return err + } + } + + _, err = b.EditComment(commentId, opts.message) + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_comment_edit_test.go b/commands/bug/bug_comment_edit_test.go new file mode 100644 index 00000000..9e110a3b --- /dev/null +++ b/commands/bug/bug_comment_edit_test.go @@ -0,0 +1,23 @@ +package bugcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func TestBugCommentEdit(t *testing.T) { + const golden = "testdata/comment/edit" + + env, bugID, commentID := testenv.NewTestEnvAndBugWithComment(t) + + opts := bugCommentEditOptions{ + message: "this is an altered bug comment", + } + require.NoError(t, runBugCommentEdit(env, opts, []string{commentID.Human()})) + + require.NoError(t, runBugComment(env, []string{bugID.Human()})) + requireCommentsEqual(t, golden, env) +} diff --git a/commands/bug/bug_comment_test.go b/commands/bug/bug_comment_test.go new file mode 100644 index 00000000..c1dc9952 --- /dev/null +++ b/commands/bug/bug_comment_test.go @@ -0,0 +1,164 @@ +package bugcmd + +import ( + "fmt" + "io/ioutil" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" + "github.com/MichaelMure/git-bug/commands/cmdtest" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func TestBugComment(t *testing.T) { + const golden = "testdata/comment/message-only" + + env, bug := testenv.NewTestEnvAndBug(t) + + require.NoError(t, runBugComment(env, []string{bug.Human()})) + + requireCommentsEqual(t, golden, env) +} + +const gitDateFormat = "Mon Jan 2 15:04:05 2006 -0700" + +type parsedComment struct { + author string + id string + date time.Time + message string +} + +type parseFunc func(*parsedComment, string) + +type commentParser struct { + t *testing.T + fn parseFunc + comments []parsedComment +} + +func parseComments(t *testing.T, env *execenv.Env) []parsedComment { + t.Helper() + + parser := &commentParser{ + t: t, + comments: []parsedComment{}, + } + + comment := &parsedComment{} + parser.fn = parser.parseAuthor + + for _, line := range strings.Split(env.Out.String(), "\n") { + parser.fn(comment, line) + } + + parser.comments = append(parser.comments, *comment) + + return parser.comments +} + +func (p *commentParser) parseAuthor(comment *parsedComment, line string) { + p.t.Helper() + + tkns := strings.Split(line, ": ") + require.Len(p.t, tkns, 2) + require.Equal(p.t, "Author", tkns[0]) + + comment.author = tkns[1] + p.fn = p.parseID +} + +func (p *commentParser) parseID(comment *parsedComment, line string) { + p.t.Helper() + + tkns := strings.Split(line, ": ") + require.Len(p.t, tkns, 2) + require.Equal(p.t, "Id", tkns[0]) + + comment.id = tkns[1] + p.fn = p.parseDate +} + +func (p *commentParser) parseDate(comment *parsedComment, line string) { + p.t.Helper() + + tkns := strings.Split(line, ": ") + require.Len(p.t, tkns, 2) + require.Equal(p.t, "Date", tkns[0]) + + date, err := time.Parse(gitDateFormat, tkns[1]) + require.NoError(p.t, err) + + comment.date = date + p.fn = p.parseMessage +} + +func (p *commentParser) parseMessage(comment *parsedComment, line string) { + p.t.Helper() + + if strings.HasPrefix(line, "Author: ") { + p.comments = append(p.comments, *comment) + comment = &parsedComment{} + p.parseAuthor(comment, line) + + return + } + + require.True(p.t, line == "" || strings.HasPrefix(line, " ")) + + comment.message = strings.Join([]string{comment.message, line}, "\n") +} + +func normalizeParsedComments(t *testing.T, comments []parsedComment) []parsedComment { + t.Helper() + + prefix := 0x1234567 + date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900") + require.NoError(t, err) + + var out []parsedComment + + for i, comment := range comments { + comment.id = fmt.Sprintf("%7x", prefix+i) + comment.date = date.Add(time.Duration(i) * time.Minute) + out = append(out, comment) + } + + return out +} + +func requireCommentsEqual(t *testing.T, golden string, env *execenv.Env) { + t.Helper() + + const goldenFilePattern = "%s-%d-golden.txt" + + comments := parseComments(t, env) + comments = normalizeParsedComments(t, comments) + + if *cmdtest.Update { + t.Log("Got here") + for i, comment := range comments { + fileName := fmt.Sprintf(goldenFilePattern, golden, i) + require.NoError(t, ioutil.WriteFile(fileName, []byte(comment.message), 0644)) + } + } + + prefix := 0x1234567 + date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900") + require.NoError(t, err) + + for i, comment := range comments { + require.Equal(t, "John Doe", comment.author) + require.Equal(t, fmt.Sprintf("%7x", prefix+i), comment.id) + require.Equal(t, date.Add(time.Duration(i)*time.Minute), comment.date) + + fileName := fmt.Sprintf(goldenFilePattern, golden, i) + exp, err := ioutil.ReadFile(fileName) + require.NoError(t, err) + require.Equal(t, strings.ReplaceAll(string(exp), "\r", ""), strings.ReplaceAll(comment.message, "\r", "")) + } +} diff --git a/commands/bug/bug_deselect.go b/commands/bug/bug_deselect.go new file mode 100644 index 00000000..7e2a86c9 --- /dev/null +++ b/commands/bug/bug_deselect.go @@ -0,0 +1,37 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugDeselectCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "deselect", + Short: "Clear the implicitly selected bug", + Example: `git bug select 2f15 +git bug comment +git bug status +git bug deselect +`, + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugDeselect(env) + }), + } + + return cmd +} + +func runBugDeselect(env *execenv.Env) error { + err := _select.Clear(env.Backend) + if err != nil { + return err + } + + return nil +} diff --git a/commands/bug/bug_label.go b/commands/bug/bug_label.go new file mode 100644 index 00000000..657fa2ca --- /dev/null +++ b/commands/bug/bug_label.go @@ -0,0 +1,43 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugLabelCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "label [BUG_ID]", + Short: "Display labels of a bug", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugLabel(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + cmd.AddCommand(newBugLabelNewCommand()) + cmd.AddCommand(newBugLabelRmCommand()) + + return cmd +} + +func runBugLabel(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + for _, l := range snap.Labels { + env.Out.Println(l) + } + + return nil +} diff --git a/commands/bug/bug_label_new.go b/commands/bug/bug_label_new.go new file mode 100644 index 00000000..f94d3dc8 --- /dev/null +++ b/commands/bug/bug_label_new.go @@ -0,0 +1,47 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/text" +) + +func newBugLabelNewCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "new [BUG_ID] LABEL...", + Short: "Add a label to a bug", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugLabelNew(env, args) + }), + ValidArgsFunction: completion.BugAndLabels(env, true), + } + + return cmd +} + +func runBugLabelNew(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + added := args + + changes, _, err := b.ChangeLabels(text.CleanupOneLineArray(added), nil) + + for _, change := range changes { + env.Out.Println(change) + } + + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_label_rm.go b/commands/bug/bug_label_rm.go new file mode 100644 index 00000000..13ce4b81 --- /dev/null +++ b/commands/bug/bug_label_rm.go @@ -0,0 +1,47 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/text" +) + +func newBugLabelRmCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "rm [BUG_ID] LABEL...", + Short: "Remove a label from a bug", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugLabelRm(env, args) + }), + ValidArgsFunction: completion.BugAndLabels(env, false), + } + + return cmd +} + +func runBugLabelRm(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + removed := args + + changes, _, err := b.ChangeLabels(nil, text.CleanupOneLineArray(removed)) + + for _, change := range changes { + env.Out.Println(change) + } + + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_new.go b/commands/bug/bug_new.go new file mode 100644 index 00000000..4f73a09c --- /dev/null +++ b/commands/bug/bug_new.go @@ -0,0 +1,77 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/commands/input" + "github.com/MichaelMure/git-bug/util/text" +) + +type bugNewOptions struct { + title string + message string + messageFile string + nonInteractive bool +} + +func newBugNewCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugNewOptions{} + + cmd := &cobra.Command{ + Use: "new", + Short: "Create a new bug", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugNew(env, options) + }), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.title, "title", "t", "", + "Provide a title to describe the issue") + flags.StringVarP(&options.message, "message", "m", "", + "Provide a message to describe the issue") + flags.StringVarP(&options.messageFile, "file", "F", "", + "Take the message from the given file. Use - to read the message from the standard input") + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runBugNew(env *execenv.Env, opts bugNewOptions) error { + var err error + if opts.messageFile != "" && opts.message == "" { + opts.title, opts.message, err = input.BugCreateFileInput(opts.messageFile) + if err != nil { + return err + } + } + + if !opts.nonInteractive && opts.messageFile == "" && (opts.message == "" || opts.title == "") { + opts.title, opts.message, err = input.BugCreateEditorInput(env.Backend, opts.title, opts.message) + + if err == input.ErrEmptyTitle { + env.Out.Println("Empty title, aborting.") + return nil + } + if err != nil { + return err + } + } + + b, _, err := env.Backend.NewBug( + text.CleanupOneLine(opts.title), + text.Cleanup(opts.message), + ) + if err != nil { + return err + } + + env.Out.Printf("%s created\n", b.Id().Human()) + + return nil +} diff --git a/commands/bug/bug_new_test.go b/commands/bug/bug_new_test.go new file mode 100644 index 00000000..210a4b0b --- /dev/null +++ b/commands/bug/bug_new_test.go @@ -0,0 +1,21 @@ +package bugcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func TestBugNew(t *testing.T) { + env, _ := testenv.NewTestEnvAndUser(t) + + err := runBugNew(env, bugNewOptions{ + nonInteractive: true, + message: "message", + title: "title", + }) + require.NoError(t, err) + require.Regexp(t, "^[0-9A-Fa-f]{7} created\n$", env.Out.String()) +} diff --git a/commands/bug/bug_rm.go b/commands/bug/bug_rm.go new file mode 100644 index 00000000..1d2a7524 --- /dev/null +++ b/commands/bug/bug_rm.go @@ -0,0 +1,46 @@ +package bugcmd + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugRmCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "rm BUG_ID", + Short: "Remove an existing bug", + Long: "Remove an existing bug in the local repository. Note removing bugs that were imported from bridges will not remove the bug on the remote, and will only remove the local copy of the bug.", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugRm(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + return cmd +} + +func runBugRm(env *execenv.Env, args []string) (err error) { + if len(args) == 0 { + return errors.New("you must provide a bug prefix to remove") + } + + err = env.Backend.RemoveBug(args[0]) + + if err != nil { + return + } + + env.Out.Printf("bug %s removed\n", args[0]) + + return +} diff --git a/commands/bug/bug_rm_test.go b/commands/bug/bug_rm_test.go new file mode 100644 index 00000000..e0c2bbc5 --- /dev/null +++ b/commands/bug/bug_rm_test.go @@ -0,0 +1,19 @@ +package bugcmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func TestBugRm(t *testing.T) { + env, bugID := testenv.NewTestEnvAndBug(t) + + exp := "bug " + bugID.Human() + " removed\n" + + require.NoError(t, runBugRm(env, []string{bugID.Human()})) + require.Equal(t, exp, env.Out.String()) + env.Out.Reset() +} diff --git a/commands/bug/bug_select.go b/commands/bug/bug_select.go new file mode 100644 index 00000000..0b1cb15c --- /dev/null +++ b/commands/bug/bug_select.go @@ -0,0 +1,62 @@ +package bugcmd + +import ( + "errors" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugSelectCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "select BUG_ID", + Short: "Select a bug for implicit use in future commands", + Example: `git bug select 2f15 +git bug comment +git bug status +`, + Long: `Select a bug for implicit use in future commands. + +This command allows you to omit any bug ID argument, for example: + git bug show +instead of + git bug show 2f153ca + +The complementary command is "git bug deselect" performing the opposite operation. +`, + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugSelect(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + return cmd +} + +func runBugSelect(env *execenv.Env, args []string) error { + if len(args) == 0 { + return errors.New("You must provide a bug id") + } + + prefix := args[0] + + b, err := env.Backend.ResolveBugPrefix(prefix) + if err != nil { + return err + } + + err = _select.Select(env.Backend, b.Id()) + if err != nil { + return err + } + + env.Out.Printf("selected bug %s: %s\n", b.Id().Human(), b.Snapshot().Title) + + return nil +} diff --git a/commands/bug/bug_show.go b/commands/bug/bug_show.go new file mode 100644 index 00000000..105b1150 --- /dev/null +++ b/commands/bug/bug_show.go @@ -0,0 +1,329 @@ +package bugcmd + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/cmdjson" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/entities/bug" + "github.com/MichaelMure/git-bug/util/colors" +) + +type bugShowOptions struct { + fields string + format string +} + +func newBugShowCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugShowOptions{} + + cmd := &cobra.Command{ + Use: "show [BUG_ID]", + Short: "Display the details of a bug", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugShow(env, options, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + fields := []string{"author", "authorEmail", "createTime", "lastEdit", "humanId", + "id", "labels", "shortId", "status", "title", "actors", "participants"} + flags.StringVarP(&options.fields, "field", "", "", + "Select field to display. Valid values are ["+strings.Join(fields, ",")+"]") + cmd.RegisterFlagCompletionFunc("by", completion.From(fields)) + flags.StringVarP(&options.format, "format", "f", "default", + "Select the output formatting style. Valid values are [default,json,org-mode]") + + return cmd +} + +func runBugShow(env *execenv.Env, opts bugShowOptions, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + if len(snap.Comments) == 0 { + return errors.New("invalid bug: no comment") + } + + if opts.fields != "" { + switch opts.fields { + case "author": + env.Out.Printf("%s\n", snap.Author.DisplayName()) + case "authorEmail": + env.Out.Printf("%s\n", snap.Author.Email()) + case "createTime": + env.Out.Printf("%s\n", snap.CreateTime.String()) + case "lastEdit": + env.Out.Printf("%s\n", snap.EditTime().String()) + case "humanId": + env.Out.Printf("%s\n", snap.Id().Human()) + case "id": + env.Out.Printf("%s\n", snap.Id()) + case "labels": + for _, l := range snap.Labels { + env.Out.Printf("%s\n", l.String()) + } + case "actors": + for _, a := range snap.Actors { + env.Out.Printf("%s\n", a.DisplayName()) + } + case "participants": + for _, p := range snap.Participants { + env.Out.Printf("%s\n", p.DisplayName()) + } + case "shortId": + env.Out.Printf("%s\n", snap.Id().Human()) + case "status": + env.Out.Printf("%s\n", snap.Status) + case "title": + env.Out.Printf("%s\n", snap.Title) + default: + return fmt.Errorf("\nUnsupported field: %s\n", opts.fields) + } + + return nil + } + + switch opts.format { + case "org-mode": + return showOrgModeFormatter(env, snap) + case "json": + return showJsonFormatter(env, snap) + case "default": + return showDefaultFormatter(env, snap) + default: + return fmt.Errorf("unknown format %s", opts.format) + } +} + +func showDefaultFormatter(env *execenv.Env, snapshot *bug.Snapshot) error { + // Header + env.Out.Printf("%s [%s] %s\n\n", + colors.Cyan(snapshot.Id().Human()), + colors.Yellow(snapshot.Status), + snapshot.Title, + ) + + env.Out.Printf("%s opened this issue %s\n", + colors.Magenta(snapshot.Author.DisplayName()), + snapshot.CreateTime.String(), + ) + + env.Out.Printf("This was last edited at %s\n\n", + snapshot.EditTime().String(), + ) + + // Labels + var labels = make([]string, len(snapshot.Labels)) + for i := range snapshot.Labels { + labels[i] = string(snapshot.Labels[i]) + } + + env.Out.Printf("labels: %s\n", + strings.Join(labels, ", "), + ) + + // Actors + var actors = make([]string, len(snapshot.Actors)) + for i := range snapshot.Actors { + actors[i] = snapshot.Actors[i].DisplayName() + } + + env.Out.Printf("actors: %s\n", + strings.Join(actors, ", "), + ) + + // Participants + var participants = make([]string, len(snapshot.Participants)) + for i := range snapshot.Participants { + participants[i] = snapshot.Participants[i].DisplayName() + } + + env.Out.Printf("participants: %s\n\n", + strings.Join(participants, ", "), + ) + + // Comments + indent := " " + + for i, comment := range snapshot.Comments { + var message string + env.Out.Printf("%s%s #%d %s <%s>\n\n", + indent, + comment.CombinedId().Human(), + i, + comment.Author.DisplayName(), + comment.Author.Email(), + ) + + if comment.Message == "" { + message = colors.BlackBold(colors.WhiteBg("No description provided.")) + } else { + message = comment.Message + } + + env.Out.Printf("%s%s\n\n\n", + indent, + message, + ) + } + + return nil +} + +type JSONBugSnapshot struct { + Id string `json:"id"` + HumanId string `json:"human_id"` + CreateTime cmdjson.Time `json:"create_time"` + EditTime cmdjson.Time `json:"edit_time"` + Status string `json:"status"` + Labels []bug.Label `json:"labels"` + Title string `json:"title"` + Author cmdjson.Identity `json:"author"` + Actors []cmdjson.Identity `json:"actors"` + Participants []cmdjson.Identity `json:"participants"` + Comments []JSONBugComment `json:"comments"` +} + +type JSONBugComment struct { + Id string `json:"id"` + HumanId string `json:"human_id"` + Author cmdjson.Identity `json:"author"` + Message string `json:"message"` +} + +func NewJSONComment(comment bug.Comment) JSONBugComment { + return JSONBugComment{ + Id: comment.CombinedId().String(), + HumanId: comment.CombinedId().Human(), + Author: cmdjson.NewIdentity(comment.Author), + Message: comment.Message, + } +} + +func showJsonFormatter(env *execenv.Env, snapshot *bug.Snapshot) error { + jsonBug := JSONBugSnapshot{ + Id: snapshot.Id().String(), + HumanId: snapshot.Id().Human(), + CreateTime: cmdjson.NewTime(snapshot.CreateTime, 0), + EditTime: cmdjson.NewTime(snapshot.EditTime(), 0), + Status: snapshot.Status.String(), + Labels: snapshot.Labels, + Title: snapshot.Title, + Author: cmdjson.NewIdentity(snapshot.Author), + } + + jsonBug.Actors = make([]cmdjson.Identity, len(snapshot.Actors)) + for i, element := range snapshot.Actors { + jsonBug.Actors[i] = cmdjson.NewIdentity(element) + } + + jsonBug.Participants = make([]cmdjson.Identity, len(snapshot.Participants)) + for i, element := range snapshot.Participants { + jsonBug.Participants[i] = cmdjson.NewIdentity(element) + } + + jsonBug.Comments = make([]JSONBugComment, len(snapshot.Comments)) + for i, comment := range snapshot.Comments { + jsonBug.Comments[i] = NewJSONComment(comment) + } + + jsonObject, _ := json.MarshalIndent(jsonBug, "", " ") + env.Out.Printf("%s\n", jsonObject) + + return nil +} + +func showOrgModeFormatter(env *execenv.Env, snapshot *bug.Snapshot) error { + // Header + env.Out.Printf("%s [%s] %s\n", + snapshot.Id().Human(), + snapshot.Status, + snapshot.Title, + ) + + env.Out.Printf("* Author: %s\n", + snapshot.Author.DisplayName(), + ) + + env.Out.Printf("* Creation Time: %s\n", + snapshot.CreateTime.String(), + ) + + env.Out.Printf("* Last Edit: %s\n", + snapshot.EditTime().String(), + ) + + // Labels + var labels = make([]string, len(snapshot.Labels)) + for i, label := range snapshot.Labels { + labels[i] = string(label) + } + + env.Out.Printf("* Labels:\n") + if len(labels) > 0 { + env.Out.Printf("** %s\n", + strings.Join(labels, "\n** "), + ) + } + + // Actors + var actors = make([]string, len(snapshot.Actors)) + for i, actor := range snapshot.Actors { + actors[i] = fmt.Sprintf("%s %s", + actor.Id().Human(), + actor.DisplayName(), + ) + } + + env.Out.Printf("* Actors:\n** %s\n", + strings.Join(actors, "\n** "), + ) + + // Participants + var participants = make([]string, len(snapshot.Participants)) + for i, participant := range snapshot.Participants { + participants[i] = fmt.Sprintf("%s %s", + participant.Id().Human(), + participant.DisplayName(), + ) + } + + env.Out.Printf("* Participants:\n** %s\n", + strings.Join(participants, "\n** "), + ) + + env.Out.Printf("* Comments:\n") + + for i, comment := range snapshot.Comments { + var message string + env.Out.Printf("** #%d %s\n", + i, comment.Author.DisplayName()) + + if comment.Message == "" { + message = "No description provided." + } else { + message = strings.ReplaceAll(comment.Message, "\n", "\n: ") + } + + env.Out.Printf(": %s\n", message) + } + + return nil +} diff --git a/commands/bug/bug_status.go b/commands/bug/bug_status.go new file mode 100644 index 00000000..b05f862c --- /dev/null +++ b/commands/bug/bug_status.go @@ -0,0 +1,41 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugStatusCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "status [BUG_ID]", + Short: "Display the status of a bug", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugStatus(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + cmd.AddCommand(newBugStatusCloseCommand()) + cmd.AddCommand(newBugStatusOpenCommand()) + + return cmd +} + +func runBugStatus(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + env.Out.Println(snap.Status) + + return nil +} diff --git a/commands/bug/bug_status_close.go b/commands/bug/bug_status_close.go new file mode 100644 index 00000000..fcd47922 --- /dev/null +++ b/commands/bug/bug_status_close.go @@ -0,0 +1,39 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugStatusCloseCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "close [BUG_ID]", + Short: "Mark a bug as closed", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugStatusClose(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + return cmd +} + +func runBugStatusClose(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + _, err = b.Close() + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_status_open.go b/commands/bug/bug_status_open.go new file mode 100644 index 00000000..e686add1 --- /dev/null +++ b/commands/bug/bug_status_open.go @@ -0,0 +1,39 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugStatusOpenCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "open [BUG_ID]", + Short: "Mark a bug as open", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugStatusOpen(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + return cmd +} + +func runBugStatusOpen(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + _, err = b.Open() + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/bug_test.go b/commands/bug/bug_test.go new file mode 100644 index 00000000..aef0346d --- /dev/null +++ b/commands/bug/bug_test.go @@ -0,0 +1,103 @@ +package bugcmd + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func Test_repairQuery(t *testing.T) { + cases := []struct { + args []string + output string + }{ + { + []string{""}, + "", + }, + { + []string{"foo"}, + "foo", + }, + { + []string{"foo", "bar"}, + "foo bar", + }, + { + []string{"foo bar", "baz"}, + "\"foo bar\" baz", + }, + { + []string{"foo:bar", "baz"}, + "foo:bar baz", + }, + { + []string{"foo:bar boo", "baz"}, + "foo:\"bar boo\" baz", + }, + } + + for _, tc := range cases { + require.Equal(t, tc.output, repairQuery(tc.args)) + } +} + +func TestBug_Format(t *testing.T) { + const expOrgMode = `^#+TODO: OPEN | CLOSED +[*] OPEN [0-9a-f]{7} \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\] John Doe: this is a bug title :: +[*]{2} Last Edited: \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\] +[*]{2} Actors: +: [0-9a-f]{7} John Doe +[*]{2} Participants: +: [0-9a-f]{7} John Doe +$` + + cases := []struct { + format string + exp string + }{ + {"default", "^[0-9a-f]{7}\topen\tthis is a bug title \tJohn Doe \t\n$"}, + {"plain", "^[0-9a-f]{7} \\[open\\] this is a bug title\n$"}, + {"compact", "^[0-9a-f]{7} open this is a bug title John Doe\n$"}, + {"id", "^[0-9a-f]{64}\n$"}, + {"org-mode", expOrgMode}, + } + + for _, testcase := range cases { + opts := bugOptions{ + sortDirection: "asc", + sortBy: "creation", + outputFormat: testcase.format, + } + + name := fmt.Sprintf("with %s format", testcase.format) + + t.Run(name, func(t *testing.T) { + env, _ := testenv.NewTestEnvAndBug(t) + + require.NoError(t, runBug(env, opts, []string{})) + require.Regexp(t, testcase.exp, env.Out.String()) + }) + } + + t.Run("with JSON format", func(t *testing.T) { + opts := bugOptions{ + sortDirection: "asc", + sortBy: "creation", + outputFormat: "json", + } + + env, _ := testenv.NewTestEnvAndBug(t) + + require.NoError(t, runBug(env, opts, []string{})) + + var bugs []JSONBugExcerpt + require.NoError(t, json.Unmarshal(env.Out.Bytes(), &bugs)) + + require.Len(t, bugs, 1) + }) +} diff --git a/commands/bug/bug_title.go b/commands/bug/bug_title.go new file mode 100644 index 00000000..98809b60 --- /dev/null +++ b/commands/bug/bug_title.go @@ -0,0 +1,40 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newBugTitleCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "title [BUG_ID]", + Short: "Display the title of a bug", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugTitle(env, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + cmd.AddCommand(newBugTitleEditCommand()) + + return cmd +} + +func runBugTitle(env *execenv.Env, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + env.Out.Println(snap.Title) + + return nil +} diff --git a/commands/bug/bug_title_edit.go b/commands/bug/bug_title_edit.go new file mode 100644 index 00000000..e71330a1 --- /dev/null +++ b/commands/bug/bug_title_edit.go @@ -0,0 +1,76 @@ +package bugcmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/commands/input" + "github.com/MichaelMure/git-bug/util/text" +) + +type bugTitleEditOptions struct { + title string + nonInteractive bool +} + +func newBugTitleEditCommand() *cobra.Command { + env := execenv.NewEnv() + options := bugTitleEditOptions{} + + cmd := &cobra.Command{ + Use: "edit [BUG_ID]", + Short: "Edit a title of a bug", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runBugTitleEdit(env, options, args) + }), + ValidArgsFunction: completion.Bug(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.title, "title", "t", "", + "Provide a title to describe the issue", + ) + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runBugTitleEdit(env *execenv.Env, opts bugTitleEditOptions, args []string) error { + b, args, err := _select.ResolveBug(env.Backend, args) + if err != nil { + return err + } + + snap := b.Snapshot() + + if opts.title == "" { + if opts.nonInteractive { + env.Err.Println("No title given. Use -m or -F option to specify a title. Aborting.") + return nil + } + opts.title, err = input.BugTitleEditorInput(env.Repo, snap.Title) + if err == input.ErrEmptyTitle { + env.Out.Println("Empty title, aborting.") + return nil + } + if err != nil { + return err + } + } + + if opts.title == snap.Title { + env.Err.Println("No change, aborting.") + } + + _, err = b.SetTitle(text.CleanupOneLine(opts.title)) + if err != nil { + return err + } + + return b.Commit() +} diff --git a/commands/bug/select/select.go b/commands/bug/select/select.go new file mode 100644 index 00000000..908ad58c --- /dev/null +++ b/commands/bug/select/select.go @@ -0,0 +1,129 @@ +package _select + +import ( + "fmt" + "io" + "io/ioutil" + "os" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entities/bug" + "github.com/MichaelMure/git-bug/entity" +) + +const selectFile = "select" + +var ErrNoValidId = errors.New("you must provide a bug id or use the \"select\" command first") + +// ResolveBug first try to resolve a bug using the first argument of the command +// line. If it fails, it fallback to the select mechanism. +// +// Returns: +// - the bug if any +// - the new list of command line arguments with the bug prefix removed if it +// has been used +// - an error if the process failed +func ResolveBug(repo *cache.RepoCache, args []string) (*cache.BugCache, []string, error) { + // At first, try to use the first argument as a bug prefix + if len(args) > 0 { + b, err := repo.ResolveBugPrefix(args[0]) + + if err == nil { + return b, args[1:], nil + } + + if err != bug.ErrBugNotExist { + return nil, nil, err + } + } + + // first arg is not a valid bug prefix, we can safely use the preselected bug if any + + b, err := selected(repo) + + // selected bug is invalid + if err == bug.ErrBugNotExist { + // we clear the selected bug + err = Clear(repo) + if err != nil { + return nil, nil, err + } + return nil, nil, ErrNoValidId + } + + // another error when reading the bug + if err != nil { + return nil, nil, err + } + + // bug is successfully retrieved + if b != nil { + return b, args, nil + } + + // no selected bug and no valid first argument + return nil, nil, ErrNoValidId +} + +// Select will select a bug for future use +func Select(repo *cache.RepoCache, id entity.Id) error { + f, err := repo.LocalStorage().OpenFile(selectFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) + if err != nil { + return err + } + + _, err = f.Write([]byte(id.String())) + if err != nil { + return err + } + + return f.Close() +} + +// Clear will clear the selected bug, if any +func Clear(repo *cache.RepoCache) error { + return repo.LocalStorage().Remove(selectFile) +} + +func selected(repo *cache.RepoCache) (*cache.BugCache, error) { + f, err := repo.LocalStorage().Open(selectFile) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } else { + return nil, err + } + } + + buf, err := ioutil.ReadAll(io.LimitReader(f, 100)) + if err != nil { + return nil, err + } + if len(buf) == 100 { + return nil, fmt.Errorf("the select file should be < 100 bytes") + } + + id := entity.Id(buf) + if err := id.Validate(); err != nil { + err = repo.LocalStorage().Remove(selectFile) + if err != nil { + return nil, errors.Wrap(err, "error while removing invalid select file") + } + + return nil, fmt.Errorf("select file in invalid, removing it") + } + + b, err := repo.ResolveBug(id) + if err != nil { + return nil, err + } + + err = f.Close() + if err != nil { + return nil, err + } + + return b, nil +} diff --git a/commands/bug/select/select_test.go b/commands/bug/select/select_test.go new file mode 100644 index 00000000..702700f4 --- /dev/null +++ b/commands/bug/select/select_test.go @@ -0,0 +1,79 @@ +package _select + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/repository" +) + +func TestSelect(t *testing.T) { + repo := repository.CreateGoGitTestRepo(t, false) + + repoCache, err := cache.NewRepoCache(repo) + require.NoError(t, err) + + _, _, err = ResolveBug(repoCache, []string{}) + require.Equal(t, ErrNoValidId, err) + + err = Select(repoCache, "invalid") + require.NoError(t, err) + + // Resolve without a pattern should fail when no bug is selected + _, _, err = ResolveBug(repoCache, []string{}) + require.Error(t, err) + + // generate a bunch of bugs + + rene, err := repoCache.NewIdentity("René Descartes", "rene@descartes.fr") + require.NoError(t, err) + + for i := 0; i < 10; i++ { + _, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) + require.NoError(t, err) + } + + // and two more for testing + b1, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) + require.NoError(t, err) + b2, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) + require.NoError(t, err) + + err = Select(repoCache, b1.Id()) + require.NoError(t, err) + + // normal select without args + b3, _, err := ResolveBug(repoCache, []string{}) + require.NoError(t, err) + require.Equal(t, b1.Id(), b3.Id()) + + // override selection with same id + b4, _, err := ResolveBug(repoCache, []string{b1.Id().String()}) + require.NoError(t, err) + require.Equal(t, b1.Id(), b4.Id()) + + // override selection with a prefix + b5, _, err := ResolveBug(repoCache, []string{b1.Id().Human()}) + require.NoError(t, err) + require.Equal(t, b1.Id(), b5.Id()) + + // args that shouldn't override + b6, _, err := ResolveBug(repoCache, []string{"arg"}) + require.NoError(t, err) + require.Equal(t, b1.Id(), b6.Id()) + + // override with a different id + b7, _, err := ResolveBug(repoCache, []string{b2.Id().String()}) + require.NoError(t, err) + require.Equal(t, b2.Id(), b7.Id()) + + err = Clear(repoCache) + require.NoError(t, err) + + // Resolve without a pattern should error again after clearing the selected bug + _, _, err = ResolveBug(repoCache, []string{}) + require.Error(t, err) +} diff --git a/commands/bug/testdata/comment/add-0-golden.txt b/commands/bug/testdata/comment/add-0-golden.txt new file mode 100644 index 00000000..44ae0c1a --- /dev/null +++ b/commands/bug/testdata/comment/add-0-golden.txt @@ -0,0 +1,3 @@ + + + this is a bug message diff --git a/commands/bug/testdata/comment/add-1-golden.txt b/commands/bug/testdata/comment/add-1-golden.txt new file mode 100644 index 00000000..bcf127c0 --- /dev/null +++ b/commands/bug/testdata/comment/add-1-golden.txt @@ -0,0 +1,6 @@ + + + this is a bug message + + + this is a bug comment diff --git a/commands/bug/testdata/comment/edit-0-golden.txt b/commands/bug/testdata/comment/edit-0-golden.txt new file mode 100644 index 00000000..44ae0c1a --- /dev/null +++ b/commands/bug/testdata/comment/edit-0-golden.txt @@ -0,0 +1,3 @@ + + + this is a bug message diff --git a/commands/bug/testdata/comment/edit-1-golden.txt b/commands/bug/testdata/comment/edit-1-golden.txt new file mode 100644 index 00000000..3d83c02b --- /dev/null +++ b/commands/bug/testdata/comment/edit-1-golden.txt @@ -0,0 +1,6 @@ + + + this is a bug message + + + this is an altered bug comment diff --git a/commands/bug/testdata/comment/message-only-0-golden.txt b/commands/bug/testdata/comment/message-only-0-golden.txt new file mode 100644 index 00000000..44ae0c1a --- /dev/null +++ b/commands/bug/testdata/comment/message-only-0-golden.txt @@ -0,0 +1,3 @@ + + + this is a bug message diff --git a/commands/bug/testenv/testenv.go b/commands/bug/testenv/testenv.go new file mode 100644 index 00000000..10f20950 --- /dev/null +++ b/commands/bug/testenv/testenv.go @@ -0,0 +1,63 @@ +package testenv + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/entity" +) + +const ( + testUserName = "John Doe" + testUserEmail = "jdoe@example.com" +) + +func NewTestEnvAndUser(t *testing.T) (*execenv.Env, entity.Id) { + t.Helper() + + testEnv := execenv.NewTestEnv(t) + + i, err := testEnv.Backend.NewIdentity(testUserName, testUserEmail) + require.NoError(t, err) + + err = testEnv.Backend.SetUserIdentity(i) + require.NoError(t, err) + + return testEnv, i.Id() +} + +const ( + testBugTitle = "this is a bug title" + testBugMessage = "this is a bug message" +) + +func NewTestEnvAndBug(t *testing.T) (*execenv.Env, entity.Id) { + t.Helper() + + testEnv, _ := NewTestEnvAndUser(t) + + b, _, err := testEnv.Backend.NewBug(testBugTitle, testBugMessage) + require.NoError(t, err) + + return testEnv, b.Id() +} + +const ( + testCommentMessage = "this is a bug comment" +) + +func NewTestEnvAndBugWithComment(t *testing.T) (*execenv.Env, entity.Id, entity.CombinedId) { + t.Helper() + + env, bugID := NewTestEnvAndBug(t) + + b, err := env.Backend.ResolveBug(bugID) + require.NoError(t, err) + + commentId, _, err := b.AddComment(testCommentMessage) + require.NoError(t, err) + + return env, bugID, commentId +} diff --git a/commands/cmdjson/json_common.go b/commands/cmdjson/json_common.go new file mode 100644 index 00000000..60e6e751 --- /dev/null +++ b/commands/cmdjson/json_common.go @@ -0,0 +1,48 @@ +package cmdjson + +import ( + "time" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/util/lamport" +) + +type Identity struct { + Id string `json:"id"` + HumanId string `json:"human_id"` + Name string `json:"name"` + Login string `json:"login"` +} + +func NewIdentity(i identity.Interface) Identity { + return Identity{ + Id: i.Id().String(), + HumanId: i.Id().Human(), + Name: i.Name(), + Login: i.Login(), + } +} + +func NewIdentityFromExcerpt(excerpt *cache.IdentityExcerpt) Identity { + return Identity{ + Id: excerpt.Id.String(), + HumanId: excerpt.Id.Human(), + Name: excerpt.Name, + Login: excerpt.Login, + } +} + +type Time struct { + Timestamp int64 `json:"timestamp"` + Time time.Time `json:"time"` + Lamport lamport.Time `json:"lamport,omitempty"` +} + +func NewTime(t time.Time, l lamport.Time) Time { + return Time{ + Timestamp: t.Unix(), + Time: t, + Lamport: l, + } +} diff --git a/commands/cmdtest/golden.go b/commands/cmdtest/golden.go new file mode 100644 index 00000000..c9a21f73 --- /dev/null +++ b/commands/cmdtest/golden.go @@ -0,0 +1,5 @@ +package cmdtest + +import "flag" + +var Update = flag.Bool("Update", false, "Update golden files") diff --git a/commands/commands.go b/commands/commands.go index 49c960ab..7d2fc37d 100644 --- a/commands/commands.go +++ b/commands/commands.go @@ -4,6 +4,8 @@ import ( "sort" "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/execenv" ) type commandOptions struct { @@ -11,7 +13,7 @@ type commandOptions struct { } func newCommandsCommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() options := commandOptions{} cmd := &cobra.Command{ @@ -32,7 +34,7 @@ func newCommandsCommand() *cobra.Command { return cmd } -func runCommands(env *Env, opts commandOptions) error { +func runCommands(env *execenv.Env, opts commandOptions) error { first := true var allCmds []*cobra.Command @@ -49,24 +51,24 @@ func runCommands(env *Env, opts commandOptions) error { for _, cmd := range allCmds { if !first { - env.out.Println() + env.Out.Println() } first = false if opts.desc { - env.out.Printf("# %s\n", cmd.Short) + env.Out.Printf("# %s\n", cmd.Short) } - env.out.Print(cmd.UseLine()) + env.Out.Print(cmd.UseLine()) if opts.desc { - env.out.Println() + env.Out.Println() } } if !opts.desc { - env.out.Println() + env.Out.Println() } return nil diff --git a/commands/comment.go b/commands/comment.go deleted file mode 100644 index 7cab447c..00000000 --- a/commands/comment.go +++ /dev/null @@ -1,50 +0,0 @@ -package commands - -import ( - text "github.com/MichaelMure/go-term-text" - "github.com/spf13/cobra" - - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/util/colors" -) - -func newCommentCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "comment [ID]", - Short: "Display or add comments to a bug.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runComment(env, args) - }), - ValidArgsFunction: completeBug(env), - } - - cmd.AddCommand(newCommentAddCommand()) - cmd.AddCommand(newCommentEditCommand()) - - return cmd -} - -func runComment(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() - - for i, comment := range snap.Comments { - if i != 0 { - env.out.Println() - } - - env.out.Printf("Author: %s\n", colors.Magenta(comment.Author.DisplayName())) - env.out.Printf("Id: %s\n", colors.Cyan(comment.CombinedId().Human())) - env.out.Printf("Date: %s\n\n", comment.FormatTime()) - env.out.Println(text.LeftPadLines(comment.Message, 4)) - } - - return nil -} diff --git a/commands/comment_add.go b/commands/comment_add.go deleted file mode 100644 index acac7994..00000000 --- a/commands/comment_add.go +++ /dev/null @@ -1,78 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/input" - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/util/text" -) - -type commentAddOptions struct { - messageFile string - message string - nonInteractive bool -} - -func newCommentAddCommand() *cobra.Command { - env := newEnv() - options := commentAddOptions{} - - cmd := &cobra.Command{ - Use: "add [ID]", - Short: "Add a new comment to a bug.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runCommentAdd(env, options, args) - }), - ValidArgsFunction: completeBug(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.messageFile, "file", "F", "", - "Take the message from the given file. Use - to read the message from the standard input") - - flags.StringVarP(&options.message, "message", "m", "", - "Provide the new message from the command line") - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runCommentAdd(env *Env, opts commentAddOptions, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - if opts.messageFile != "" && opts.message == "" { - opts.message, err = input.BugCommentFileInput(opts.messageFile) - if err != nil { - return err - } - } - - if opts.messageFile == "" && opts.message == "" { - if opts.nonInteractive { - env.err.Println("No message given. Use -m or -F option to specify a message. Aborting.") - return nil - } - opts.message, err = input.BugCommentEditorInput(env.backend, "") - if err == input.ErrEmptyMessage { - env.err.Println("Empty message, aborting.") - return nil - } - if err != nil { - return err - } - } - - _, _, err = b.AddComment(text.Cleanup(opts.message)) - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/comment_add_test.go b/commands/comment_add_test.go deleted file mode 100644 index 34ff3743..00000000 --- a/commands/comment_add_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package commands - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func newTestEnvAndBugWithComment(t *testing.T) (*testEnv, string, string) { - t.Helper() - - env, bugID := newTestEnvAndBug(t) - - opts := commentAddOptions{ - message: "this is a bug comment", - } - require.NoError(t, runCommentAdd(env.env, opts, []string{bugID})) - require.NoError(t, runComment(env.env, []string{bugID})) - comments := parseComments(t, env) - require.Len(t, comments, 2) - - env.out.Reset() - - return env, bugID, comments[1].id -} - -func TestCommentAdd(t *testing.T) { - const golden = "testdata/comment/add" - - env, bugID, _ := newTestEnvAndBugWithComment(t) - require.NoError(t, runComment(env.env, []string{bugID})) - requireCommentsEqual(t, golden, env) -} diff --git a/commands/comment_edit.go b/commands/comment_edit.go deleted file mode 100644 index 91c6d809..00000000 --- a/commands/comment_edit.go +++ /dev/null @@ -1,76 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/input" -) - -type commentEditOptions struct { - messageFile string - message string - nonInteractive bool -} - -func newCommentEditCommand() *cobra.Command { - env := newEnv() - options := commentEditOptions{} - - cmd := &cobra.Command{ - Use: "edit [COMMENT_ID]", - Short: "Edit an existing comment on a bug.", - Args: cobra.ExactArgs(1), - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runCommentEdit(env, options, args) - }), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.messageFile, "file", "F", "", - "Take the message from the given file. Use - to read the message from the standard input") - - flags.StringVarP(&options.message, "message", "m", "", - "Provide the new message from the command line") - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runCommentEdit(env *Env, opts commentEditOptions, args []string) error { - b, commentId, err := env.backend.ResolveComment(args[0]) - if err != nil { - return err - } - - if opts.messageFile != "" && opts.message == "" { - opts.message, err = input.BugCommentFileInput(opts.messageFile) - if err != nil { - return err - } - } - - if opts.messageFile == "" && opts.message == "" { - if opts.nonInteractive { - env.err.Println("No message given. Use -m or -F option to specify a message. Aborting.") - return nil - } - opts.message, err = input.BugCommentEditorInput(env.backend, "") - if err == input.ErrEmptyMessage { - env.err.Println("Empty message, aborting.") - return nil - } - if err != nil { - return err - } - } - - _, err = b.EditComment(commentId, opts.message) - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/comment_edit_test.go b/commands/comment_edit_test.go deleted file mode 100644 index 50c1850b..00000000 --- a/commands/comment_edit_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package commands - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestCommentEdit(t *testing.T) { - const golden = "testdata/comment/edit" - - env, bugID, commentID := newTestEnvAndBugWithComment(t) - - opts := commentEditOptions{ - message: "this is an altered bug comment", - } - require.NoError(t, runCommentEdit(env.env, opts, []string{commentID})) - - require.NoError(t, runComment(env.env, []string{bugID})) - requireCommentsEqual(t, golden, env) -} diff --git a/commands/comment_test.go b/commands/comment_test.go deleted file mode 100644 index 43062ed0..00000000 --- a/commands/comment_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package commands - -import ( - "fmt" - "io/ioutil" - "strings" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -func TestComment(t *testing.T) { - const golden = "testdata/comment/message-only" - - env, bug := newTestEnvAndBug(t) - - require.NoError(t, runComment(env.env, []string{bug})) - - requireCommentsEqual(t, golden, env) -} - -const gitDateFormat = "Mon Jan 2 15:04:05 2006 -0700" - -type parsedComment struct { - author string - id string - date time.Time - message string -} - -type parseFunc func(*parsedComment, string) - -type commentParser struct { - t *testing.T - fn parseFunc - comments []parsedComment -} - -func parseComments(t *testing.T, env *testEnv) []parsedComment { - t.Helper() - - parser := &commentParser{ - t: t, - comments: []parsedComment{}, - } - - comment := &parsedComment{} - parser.fn = parser.parseAuthor - - for _, line := range strings.Split(env.out.String(), "\n") { - parser.fn(comment, line) - } - - parser.comments = append(parser.comments, *comment) - - return parser.comments -} - -func (p *commentParser) parseAuthor(comment *parsedComment, line string) { - p.t.Helper() - - tkns := strings.Split(line, ": ") - require.Len(p.t, tkns, 2) - require.Equal(p.t, "Author", tkns[0]) - - comment.author = tkns[1] - p.fn = p.parseID -} - -func (p *commentParser) parseID(comment *parsedComment, line string) { - p.t.Helper() - - tkns := strings.Split(line, ": ") - require.Len(p.t, tkns, 2) - require.Equal(p.t, "Id", tkns[0]) - - comment.id = tkns[1] - p.fn = p.parseDate -} - -func (p *commentParser) parseDate(comment *parsedComment, line string) { - p.t.Helper() - - tkns := strings.Split(line, ": ") - require.Len(p.t, tkns, 2) - require.Equal(p.t, "Date", tkns[0]) - - date, err := time.Parse(gitDateFormat, tkns[1]) - require.NoError(p.t, err) - - comment.date = date - p.fn = p.parseMessage -} - -func (p *commentParser) parseMessage(comment *parsedComment, line string) { - p.t.Helper() - - if strings.HasPrefix(line, "Author: ") { - p.comments = append(p.comments, *comment) - comment = &parsedComment{} - p.parseAuthor(comment, line) - - return - } - - require.True(p.t, line == "" || strings.HasPrefix(line, " ")) - - comment.message = strings.Join([]string{comment.message, line}, "\n") -} - -func normalizeParsedComments(t *testing.T, comments []parsedComment) []parsedComment { - t.Helper() - - prefix := 0x1234567 - date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900") - require.NoError(t, err) - - out := []parsedComment{} - - for i, comment := range comments { - comment.id = fmt.Sprintf("%7x", prefix+i) - comment.date = date.Add(time.Duration(i) * time.Minute) - out = append(out, comment) - } - - return out -} - -func requireCommentsEqual(t *testing.T, golden string, env *testEnv) { - t.Helper() - - const goldenFilePatter = "%s-%d-golden.txt" - - comments := parseComments(t, env) - comments = normalizeParsedComments(t, comments) - - if *update { - t.Log("Got here") - for i, comment := range comments { - fileName := fmt.Sprintf(goldenFilePatter, golden, i) - require.NoError(t, ioutil.WriteFile(fileName, []byte(comment.message), 0644)) - } - } - - prefix := 0x1234567 - date, err := time.Parse(gitDateFormat, "Fri Aug 19 07:00:00 2022 +1900") - require.NoError(t, err) - - for i, comment := range comments { - require.Equal(t, "John Doe", comment.author) - require.Equal(t, fmt.Sprintf("%7x", prefix+i), comment.id) - require.Equal(t, date.Add(time.Duration(i)*time.Minute), comment.date) - - fileName := fmt.Sprintf(goldenFilePatter, golden, i) - exp, err := ioutil.ReadFile(fileName) - require.NoError(t, err) - require.Equal(t, strings.ReplaceAll(string(exp), "\r", ""), strings.ReplaceAll(comment.message, "\r", "")) - } -} diff --git a/commands/completion/helper_completion.go b/commands/completion/helper_completion.go new file mode 100644 index 00000000..27fbd615 --- /dev/null +++ b/commands/completion/helper_completion.go @@ -0,0 +1,343 @@ +package completion + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/bug/select" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/entities/bug" +) + +type ValidArgsFunction func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) + +func handleError(err error) (completions []string, directives cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveError +} + +func Bridge(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + bridges, err := bridge.ConfiguredBridges(env.Backend) + if err != nil { + return handleError(err) + } + + completions = make([]string, len(bridges)) + for i, bridge := range bridges { + completions[i] = bridge + "\t" + "Bridge" + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func BridgeAuth(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + creds, err := auth.List(env.Backend) + if err != nil { + return handleError(err) + } + + completions = make([]string, len(creds)) + for i, cred := range creds { + meta := make([]string, 0, len(cred.Metadata())) + for k, v := range cred.Metadata() { + meta = append(meta, k+":"+v) + } + sort.Strings(meta) + metaFmt := strings.Join(meta, ",") + + completions[i] = cred.ID().Human() + "\t" + cred.Target() + " " + string(cred.Kind()) + " " + metaFmt + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func Bug(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + return bugWithBackend(env.Backend, toComplete) + } +} + +func bugWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + allIds := backend.AllBugsIds() + bugExcerpt := make([]*cache.BugExcerpt, len(allIds)) + for i, id := range allIds { + var err error + bugExcerpt[i], err = backend.ResolveBugExcerpt(id) + if err != nil { + return handleError(err) + } + } + + for i, id := range allIds { + if strings.Contains(id.String(), strings.TrimSpace(toComplete)) { + completions = append(completions, id.Human()+"\t"+bugExcerpt[i].Title) + } + } + + return completions, cobra.ShellCompDirectiveNoFileComp +} + +func BugAndLabels(env *execenv.Env, addOrRemove bool) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + b, args, err := _select.ResolveBug(env.Backend, args) + if err == _select.ErrNoValidId { + // we need a bug first to complete labels + return bugWithBackend(env.Backend, toComplete) + } + if err != nil { + return handleError(err) + } + + snap := b.Snapshot() + + seenLabels := map[bug.Label]bool{} + for _, label := range args { + seenLabels[bug.Label(label)] = addOrRemove + } + + var labels []bug.Label + if addOrRemove { + for _, label := range snap.Labels { + seenLabels[label] = true + } + + allLabels := env.Backend.ValidLabels() + labels = make([]bug.Label, 0, len(allLabels)) + for _, label := range allLabels { + if !seenLabels[label] { + labels = append(labels, label) + } + } + } else { + labels = make([]bug.Label, 0, len(snap.Labels)) + for _, label := range snap.Labels { + if seenLabels[label] { + labels = append(labels, label) + } + } + } + + completions = make([]string, len(labels)) + for i, label := range labels { + completions[i] = string(label) + "\t" + "Label" + } + + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func From(choices []string) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return choices, cobra.ShellCompDirectiveNoFileComp + } +} + +func GitRemote(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + remoteMap, err := env.Backend.GetRemotes() + if err != nil { + return handleError(err) + } + completions = make([]string, 0, len(remoteMap)) + for remote, url := range remoteMap { + completions = append(completions, remote+"\t"+"Remote: "+url) + } + sort.Strings(completions) + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func Label(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + labels := env.Backend.ValidLabels() + completions = make([]string, len(labels)) + for i, label := range labels { + if strings.Contains(label.String(), " ") { + completions[i] = fmt.Sprintf("\"%s\"\tLabel", label.String()) + } else { + completions[i] = fmt.Sprintf("%s\tLabel", label.String()) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func Ls(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if strings.HasPrefix(toComplete, "status:") { + completions = append(completions, "status:open\tOpen bugs") + completions = append(completions, "status:closed\tClosed bugs") + return completions, cobra.ShellCompDirectiveDefault + } + + byPerson := []string{"author:", "participant:", "actor:"} + byLabel := []string{"label:", "no:"} + needBackend := false + for _, key := range append(byPerson, byLabel...) { + if strings.HasPrefix(toComplete, key) { + needBackend = true + } + } + + if needBackend { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + } + + for _, key := range byPerson { + if !strings.HasPrefix(toComplete, key) { + continue + } + ids := env.Backend.AllIdentityIds() + completions = make([]string, len(ids)) + for i, id := range ids { + user, err := env.Backend.ResolveIdentityExcerpt(id) + if err != nil { + return handleError(err) + } + var handle string + if user.Login != "" { + handle = user.Login + } else { + // "author:John Doe" does not work yet, so use the first name. + handle = strings.Split(user.Name, " ")[0] + } + completions[i] = key + handle + "\t" + user.DisplayName() + } + return completions, cobra.ShellCompDirectiveNoFileComp + } + + for _, key := range byLabel { + if !strings.HasPrefix(toComplete, key) { + continue + } + labels := env.Backend.ValidLabels() + completions = make([]string, len(labels)) + for i, label := range labels { + if strings.Contains(label.String(), " ") { + completions[i] = key + "\"" + string(label) + "\"" + } else { + completions[i] = key + string(label) + } + } + return completions, cobra.ShellCompDirectiveNoFileComp + } + + completions = []string{ + "actor:\tFilter by actor", + "author:\tFilter by author", + "label:\tFilter by label", + "no:\tExclude bugs by label", + "participant:\tFilter by participant", + "status:\tFilter by open/close status", + "title:\tFilter by title", + } + return completions, cobra.ShellCompDirectiveNoSpace + } +} + +func User(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + ids := env.Backend.AllIdentityIds() + completions = make([]string, len(ids)) + for i, id := range ids { + user, err := env.Backend.ResolveIdentityExcerpt(id) + if err != nil { + return handleError(err) + } + completions[i] = user.Id.Human() + "\t" + user.DisplayName() + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +func UserForQuery(env *execenv.Env) ValidArgsFunction { + return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { + if err := execenv.LoadBackend(env)(cmd, args); err != nil { + return handleError(err) + } + defer func() { + _ = env.Backend.Close() + }() + + ids := env.Backend.AllIdentityIds() + completions = make([]string, len(ids)) + for i, id := range ids { + user, err := env.Backend.ResolveIdentityExcerpt(id) + if err != nil { + return handleError(err) + } + var handle string + if user.Login != "" { + handle = user.Login + } else { + // "author:John Doe" does not work yet, so use the first name. + handle = strings.Split(user.Name, " ")[0] + } + completions[i] = handle + "\t" + user.DisplayName() + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/commands/deselect.go b/commands/deselect.go deleted file mode 100644 index d8c44dd7..00000000 --- a/commands/deselect.go +++ /dev/null @@ -1,36 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/select" -) - -func newDeselectCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "deselect", - Short: "Clear the implicitly selected bug.", - Example: `git bug select 2f15 -git bug comment -git bug status -git bug deselect -`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runDeselect(env) - }), - } - - return cmd -} - -func runDeselect(env *Env) error { - err := _select.Clear(env.backend) - if err != nil { - return err - } - - return nil -} diff --git a/commands/env.go b/commands/env.go deleted file mode 100644 index 11b91c4b..00000000 --- a/commands/env.go +++ /dev/null @@ -1,160 +0,0 @@ -package commands - -import ( - "fmt" - "io" - "os" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/entities/bug" - "github.com/MichaelMure/git-bug/entities/identity" - "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/interrupt" -) - -const gitBugNamespace = "git-bug" - -// Env is the environment of a command -type Env struct { - repo repository.ClockedRepo - backend *cache.RepoCache - out out - err out -} - -func newEnv() *Env { - return &Env{ - repo: nil, - out: out{Writer: os.Stdout}, - err: out{Writer: os.Stderr}, - } -} - -type out struct { - io.Writer -} - -func (o out) Printf(format string, a ...interface{}) { - _, _ = fmt.Fprintf(o, format, a...) -} - -func (o out) Print(a ...interface{}) { - _, _ = fmt.Fprint(o, a...) -} - -func (o out) Println(a ...interface{}) { - _, _ = fmt.Fprintln(o, a...) -} - -// loadRepo is a pre-run function that load the repository for use in a command -func loadRepo(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - cwd, err := os.Getwd() - if err != nil { - return fmt.Errorf("unable to get the current working directory: %q", err) - } - - env.repo, err = repository.OpenGoGitRepo(cwd, gitBugNamespace, []repository.ClockLoader{bug.ClockLoader}) - if err == repository.ErrNotARepo { - return fmt.Errorf("%s must be run from within a git repo", rootCommandName) - } - - if err != nil { - return err - } - - return nil - } -} - -// loadRepoEnsureUser is the same as loadRepo, but also ensure that the user has configured -// an identity. Use this pre-run function when an error after using the configured user won't -// do. -func loadRepoEnsureUser(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := loadRepo(env)(cmd, args) - if err != nil { - return err - } - - _, err = identity.GetUserIdentity(env.repo) - if err != nil { - return err - } - - return nil - } -} - -// loadBackend is a pre-run function that load the repository and the backend for use in a command -// When using this function you also need to use closeBackend as a post-run -func loadBackend(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := loadRepo(env)(cmd, args) - if err != nil { - return err - } - - env.backend, err = cache.NewRepoCache(env.repo) - if err != nil { - return err - } - - cleaner := func(env *Env) interrupt.CleanerFunc { - return func() error { - if env.backend != nil { - err := env.backend.Close() - env.backend = nil - return err - } - return nil - } - } - - // Cleanup properly on interrupt - interrupt.RegisterCleaner(cleaner(env)) - return nil - } -} - -// loadBackendEnsureUser is the same as loadBackend, but also ensure that the user has configured -// an identity. Use this pre-run function when an error after using the configured user won't -// do. -func loadBackendEnsureUser(env *Env) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - err := loadBackend(env)(cmd, args) - if err != nil { - return err - } - - _, err = identity.GetUserIdentity(env.repo) - if err != nil { - return err - } - - return nil - } -} - -// closeBackend is a wrapper for a RunE function that will close the backend properly -// if it has been opened. -// This wrapper style is necessary because a Cobra PostE function does not run if RunE return an error. -func closeBackend(env *Env, runE func(cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error { - return func(cmd *cobra.Command, args []string) error { - errRun := runE(cmd, args) - - if env.backend == nil { - return nil - } - err := env.backend.Close() - env.backend = nil - - // prioritize the RunE error - if errRun != nil { - return errRun - } - return err - } -} diff --git a/commands/env_testing.go b/commands/env_testing.go deleted file mode 100644 index 1493a190..00000000 --- a/commands/env_testing.go +++ /dev/null @@ -1,40 +0,0 @@ -package commands - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/repository" -) - -type testEnv struct { - env *Env - out *bytes.Buffer -} - -func newTestEnv(t *testing.T) *testEnv { - t.Helper() - - repo := repository.CreateGoGitTestRepo(t, false) - - buf := new(bytes.Buffer) - - backend, err := cache.NewRepoCache(repo) - require.NoError(t, err) - t.Cleanup(func() { - backend.Close() - }) - - return &testEnv{ - env: &Env{ - repo: repo, - backend: backend, - out: out{Writer: buf}, - err: out{Writer: buf}, - }, - out: buf, - } -} diff --git a/commands/execenv/env.go b/commands/execenv/env.go new file mode 100644 index 00000000..a63f835a --- /dev/null +++ b/commands/execenv/env.go @@ -0,0 +1,191 @@ +package execenv + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entities/bug" + "github.com/MichaelMure/git-bug/entities/identity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +const RootCommandName = "git-bug" + +const gitBugNamespace = "git-bug" + +// Env is the environment of a command +type Env struct { + Repo repository.ClockedRepo + Backend *cache.RepoCache + Out Out + Err Out +} + +func NewEnv() *Env { + return &Env{ + Repo: nil, + Out: out{Writer: os.Stdout}, + Err: out{Writer: os.Stderr}, + } +} + +type Out interface { + io.Writer + Printf(format string, a ...interface{}) + Print(a ...interface{}) + Println(a ...interface{}) + + // String returns what have been written in the output before, as a string. + // This only works in test scenario. + String() string + // Bytes returns what have been written in the output before, as []byte. + // This only works in test scenario. + Bytes() []byte + // Reset clear what has been recorded as written in the output before. + // This only works in test scenario. + Reset() +} + +type out struct { + io.Writer +} + +func (o out) Printf(format string, a ...interface{}) { + _, _ = fmt.Fprintf(o, format, a...) +} + +func (o out) Print(a ...interface{}) { + _, _ = fmt.Fprint(o, a...) +} + +func (o out) Println(a ...interface{}) { + _, _ = fmt.Fprintln(o, a...) +} + +func (o out) String() string { + panic("only work with a test env") +} + +func (o out) Bytes() []byte { + panic("only work with a test env") +} + +func (o out) Reset() { + panic("only work with a test env") +} + +// LoadRepo is a pre-run function that load the repository for use in a command +func LoadRepo(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("unable to get the current working directory: %q", err) + } + + env.Repo, err = repository.OpenGoGitRepo(cwd, gitBugNamespace, []repository.ClockLoader{bug.ClockLoader}) + if err == repository.ErrNotARepo { + return fmt.Errorf("%s must be run from within a git Repo", RootCommandName) + } + + if err != nil { + return err + } + + return nil + } +} + +// LoadRepoEnsureUser is the same as LoadRepo, but also ensure that the user has configured +// an identity. Use this pre-run function when an error after using the configured user won't +// do. +func LoadRepoEnsureUser(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadRepo(env)(cmd, args) + if err != nil { + return err + } + + _, err = identity.GetUserIdentity(env.Repo) + if err != nil { + return err + } + + return nil + } +} + +// LoadBackend is a pre-run function that load the repository and the Backend for use in a command +// When using this function you also need to use CloseBackend as a post-run +func LoadBackend(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadRepo(env)(cmd, args) + if err != nil { + return err + } + + env.Backend, err = cache.NewRepoCache(env.Repo) + if err != nil { + return err + } + + cleaner := func(env *Env) interrupt.CleanerFunc { + return func() error { + if env.Backend != nil { + err := env.Backend.Close() + env.Backend = nil + return err + } + return nil + } + } + + // Cleanup properly on interrupt + interrupt.RegisterCleaner(cleaner(env)) + return nil + } +} + +// LoadBackendEnsureUser is the same as LoadBackend, but also ensure that the user has configured +// an identity. Use this pre-run function when an error after using the configured user won't +// do. +func LoadBackendEnsureUser(env *Env) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + err := LoadBackend(env)(cmd, args) + if err != nil { + return err + } + + _, err = identity.GetUserIdentity(env.Repo) + if err != nil { + return err + } + + return nil + } +} + +// CloseBackend is a wrapper for a RunE function that will close the Backend properly +// if it has been opened. +// This wrapper style is necessary because a Cobra PostE function does not run if RunE return an error. +func CloseBackend(env *Env, runE func(cmd *cobra.Command, args []string) error) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + errRun := runE(cmd, args) + + if env.Backend == nil { + return nil + } + err := env.Backend.Close() + env.Backend = nil + + // prioritize the RunE error + if errRun != nil { + return errRun + } + return err + } +} diff --git a/commands/execenv/env_testing.go b/commands/execenv/env_testing.go new file mode 100644 index 00000000..7d9fbd60 --- /dev/null +++ b/commands/execenv/env_testing.go @@ -0,0 +1,48 @@ +package execenv + +import ( + "bytes" + "fmt" + "testing" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/repository" + "github.com/stretchr/testify/require" +) + +type TestOut struct { + *bytes.Buffer +} + +func (te *TestOut) Printf(format string, a ...interface{}) { + _, _ = fmt.Fprintf(te.Buffer, format, a...) +} + +func (te *TestOut) Print(a ...interface{}) { + _, _ = fmt.Fprint(te.Buffer, a...) +} + +func (te *TestOut) Println(a ...interface{}) { + _, _ = fmt.Fprintln(te.Buffer, a...) +} + +func NewTestEnv(t *testing.T) *Env { + t.Helper() + + repo := repository.CreateGoGitTestRepo(t, false) + + buf := new(bytes.Buffer) + + backend, err := cache.NewRepoCache(repo) + require.NoError(t, err) + t.Cleanup(func() { + backend.Close() + }) + + return &Env{ + Repo: repo, + Backend: backend, + Out: &TestOut{buf}, + Err: &TestOut{buf}, + } +} diff --git a/commands/golden_test.go b/commands/golden_test.go deleted file mode 100644 index 9fcee0d6..00000000 --- a/commands/golden_test.go +++ /dev/null @@ -1,5 +0,0 @@ -package commands - -import "flag" - -var update = flag.Bool("update", false, "update golden files") diff --git a/commands/helper_completion.go b/commands/helper_completion.go deleted file mode 100644 index 847a0288..00000000 --- a/commands/helper_completion.go +++ /dev/null @@ -1,342 +0,0 @@ -package commands - -import ( - "fmt" - "sort" - "strings" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/bridge" - "github.com/MichaelMure/git-bug/bridge/core/auth" - "github.com/MichaelMure/git-bug/cache" - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/entities/bug" -) - -type validArgsFunction func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) - -func completionHandlerError(err error) (completions []string, directives cobra.ShellCompDirective) { - return nil, cobra.ShellCompDirectiveError -} - -func completeBridge(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - bridges, err := bridge.ConfiguredBridges(env.backend) - if err != nil { - return completionHandlerError(err) - } - - completions = make([]string, len(bridges)) - for i, bridge := range bridges { - completions[i] = bridge + "\t" + "Bridge" - } - - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeBridgeAuth(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - creds, err := auth.List(env.backend) - if err != nil { - return completionHandlerError(err) - } - - completions = make([]string, len(creds)) - for i, cred := range creds { - meta := make([]string, 0, len(cred.Metadata())) - for k, v := range cred.Metadata() { - meta = append(meta, k+":"+v) - } - sort.Strings(meta) - metaFmt := strings.Join(meta, ",") - - completions[i] = cred.ID().Human() + "\t" + cred.Target() + " " + string(cred.Kind()) + " " + metaFmt - } - - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeBug(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - return completeBugWithBackend(env.backend, toComplete) - } -} - -func completeBugWithBackend(backend *cache.RepoCache, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - allIds := backend.AllBugsIds() - bugExcerpt := make([]*cache.BugExcerpt, len(allIds)) - for i, id := range allIds { - var err error - bugExcerpt[i], err = backend.ResolveBugExcerpt(id) - if err != nil { - return completionHandlerError(err) - } - } - - for i, id := range allIds { - if strings.Contains(id.String(), strings.TrimSpace(toComplete)) { - completions = append(completions, id.Human()+"\t"+bugExcerpt[i].Title) - } - } - - return completions, cobra.ShellCompDirectiveNoFileComp -} - -func completeBugAndLabels(env *Env, addOrRemove bool) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - b, args, err := _select.ResolveBug(env.backend, args) - if err == _select.ErrNoValidId { - // we need a bug first to complete labels - return completeBugWithBackend(env.backend, toComplete) - } - if err != nil { - return completionHandlerError(err) - } - - snap := b.Snapshot() - - seenLabels := map[bug.Label]bool{} - for _, label := range args { - seenLabels[bug.Label(label)] = addOrRemove - } - - var labels []bug.Label - if addOrRemove { - for _, label := range snap.Labels { - seenLabels[label] = true - } - - allLabels := env.backend.ValidLabels() - labels = make([]bug.Label, 0, len(allLabels)) - for _, label := range allLabels { - if !seenLabels[label] { - labels = append(labels, label) - } - } - } else { - labels = make([]bug.Label, 0, len(snap.Labels)) - for _, label := range snap.Labels { - if seenLabels[label] { - labels = append(labels, label) - } - } - } - - completions = make([]string, len(labels)) - for i, label := range labels { - completions[i] = string(label) + "\t" + "Label" - } - - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeFrom(choices []string) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return choices, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeGitRemote(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - remoteMap, err := env.backend.GetRemotes() - if err != nil { - return completionHandlerError(err) - } - completions = make([]string, 0, len(remoteMap)) - for remote, url := range remoteMap { - completions = append(completions, remote+"\t"+"Remote: "+url) - } - sort.Strings(completions) - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeLabel(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - labels := env.backend.ValidLabels() - completions = make([]string, len(labels)) - for i, label := range labels { - if strings.Contains(label.String(), " ") { - completions[i] = fmt.Sprintf("\"%s\"\tLabel", label.String()) - } else { - completions[i] = fmt.Sprintf("%s\tLabel", label.String()) - } - } - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeLs(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if strings.HasPrefix(toComplete, "status:") { - completions = append(completions, "status:open\tOpen bugs") - completions = append(completions, "status:closed\tClosed bugs") - return completions, cobra.ShellCompDirectiveDefault - } - - byPerson := []string{"author:", "participant:", "actor:"} - byLabel := []string{"label:", "no:"} - needBackend := false - for _, key := range append(byPerson, byLabel...) { - if strings.HasPrefix(toComplete, key) { - needBackend = true - } - } - - if needBackend { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - } - - for _, key := range byPerson { - if !strings.HasPrefix(toComplete, key) { - continue - } - ids := env.backend.AllIdentityIds() - completions = make([]string, len(ids)) - for i, id := range ids { - user, err := env.backend.ResolveIdentityExcerpt(id) - if err != nil { - return completionHandlerError(err) - } - var handle string - if user.Login != "" { - handle = user.Login - } else { - // "author:John Doe" does not work yet, so use the first name. - handle = strings.Split(user.Name, " ")[0] - } - completions[i] = key + handle + "\t" + user.DisplayName() - } - return completions, cobra.ShellCompDirectiveNoFileComp - } - - for _, key := range byLabel { - if !strings.HasPrefix(toComplete, key) { - continue - } - labels := env.backend.ValidLabels() - completions = make([]string, len(labels)) - for i, label := range labels { - if strings.Contains(label.String(), " ") { - completions[i] = key + "\"" + string(label) + "\"" - } else { - completions[i] = key + string(label) - } - } - return completions, cobra.ShellCompDirectiveNoFileComp - } - - completions = []string{ - "actor:\tFilter by actor", - "author:\tFilter by author", - "label:\tFilter by label", - "no:\tExclude bugs by label", - "participant:\tFilter by participant", - "status:\tFilter by open/close status", - "title:\tFilter by title", - } - return completions, cobra.ShellCompDirectiveNoSpace - } -} - -func completeUser(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - ids := env.backend.AllIdentityIds() - completions = make([]string, len(ids)) - for i, id := range ids { - user, err := env.backend.ResolveIdentityExcerpt(id) - if err != nil { - return completionHandlerError(err) - } - completions[i] = user.Id.Human() + "\t" + user.DisplayName() - } - return completions, cobra.ShellCompDirectiveNoFileComp - } -} - -func completeUserForQuery(env *Env) validArgsFunction { - return func(cmd *cobra.Command, args []string, toComplete string) (completions []string, directives cobra.ShellCompDirective) { - if err := loadBackend(env)(cmd, args); err != nil { - return completionHandlerError(err) - } - defer func() { - _ = env.backend.Close() - }() - - ids := env.backend.AllIdentityIds() - completions = make([]string, len(ids)) - for i, id := range ids { - user, err := env.backend.ResolveIdentityExcerpt(id) - if err != nil { - return completionHandlerError(err) - } - var handle string - if user.Login != "" { - handle = user.Login - } else { - // "author:John Doe" does not work yet, so use the first name. - handle = strings.Split(user.Name, " ")[0] - } - completions[i] = handle + "\t" + user.DisplayName() - } - return completions, cobra.ShellCompDirectiveNoFileComp - } -} diff --git a/commands/input/input.go b/commands/input/input.go index e9c1be1b..ee343cd8 100644 --- a/commands/input/input.go +++ b/commands/input/input.go @@ -25,7 +25,7 @@ const messageFilename = "BUG_MESSAGE_EDITMSG" // ErrEmptyMessage is returned when the required message has not been entered var ErrEmptyMessage = errors.New("empty message") -// ErrEmptyMessage is returned when the required title has not been entered +// ErrEmptyTitle is returned when the required title has not been entered var ErrEmptyTitle = errors.New("empty title") const bugTitleCommentTemplate = `%s%s diff --git a/commands/json_common.go b/commands/json_common.go deleted file mode 100644 index 3ceee1ec..00000000 --- a/commands/json_common.go +++ /dev/null @@ -1,55 +0,0 @@ -package commands - -import ( - "time" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/entities/identity" - "github.com/MichaelMure/git-bug/util/lamport" -) - -type JSONIdentity struct { - Id string `json:"id"` - HumanId string `json:"human_id"` - Name string `json:"name"` - Login string `json:"login"` -} - -func NewJSONIdentity(i identity.Interface) JSONIdentity { - return JSONIdentity{ - Id: i.Id().String(), - HumanId: i.Id().Human(), - Name: i.Name(), - Login: i.Login(), - } -} - -func NewJSONIdentityFromExcerpt(excerpt *cache.IdentityExcerpt) JSONIdentity { - return JSONIdentity{ - Id: excerpt.Id.String(), - HumanId: excerpt.Id.Human(), - Name: excerpt.Name, - Login: excerpt.Login, - } -} - -func NewJSONIdentityFromLegacyExcerpt(excerpt *cache.LegacyAuthorExcerpt) JSONIdentity { - return JSONIdentity{ - Name: excerpt.Name, - Login: excerpt.Login, - } -} - -type JSONTime struct { - Timestamp int64 `json:"timestamp"` - Time time.Time `json:"time"` - Lamport lamport.Time `json:"lamport,omitempty"` -} - -func NewJSONTime(t time.Time, l lamport.Time) JSONTime { - return JSONTime{ - Timestamp: t.Unix(), - Time: t, - Lamport: l, - } -} diff --git a/commands/label.go b/commands/label.go index ff4d0151..70090d26 100644 --- a/commands/label.go +++ b/commands/label.go @@ -3,39 +3,32 @@ package commands import ( "github.com/spf13/cobra" - _select "github.com/MichaelMure/git-bug/commands/select" + "github.com/MichaelMure/git-bug/commands/execenv" ) func newLabelCommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() cmd := &cobra.Command{ - Use: "label [ID]", - Short: "Display, add or remove labels to/from a bug.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLabel(env, args) + Use: "label", + Short: "List valid labels", + Long: `List valid labels. + +Note: in the future, a proper label policy could be implemented where valid labels are defined in a configuration file. Until that, the default behavior is to return the list of labels already used.`, + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runLabel(env) }), - ValidArgsFunction: completeBug(env), } - cmd.AddCommand(newLabelAddCommand()) - cmd.AddCommand(newLabelLsCommand()) - cmd.AddCommand(newLabelRmCommand()) - return cmd } -func runLabel(env *Env, args []string) error { - b, _, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() +func runLabel(env *execenv.Env) error { + labels := env.Backend.ValidLabels() - for _, l := range snap.Labels { - env.out.Println(l) + for _, l := range labels { + env.Out.Println(l) } return nil diff --git a/commands/label_add.go b/commands/label_add.go deleted file mode 100644 index 65439a4a..00000000 --- a/commands/label_add.go +++ /dev/null @@ -1,45 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/util/text" -) - -func newLabelAddCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "add [ID] LABEL...", - Short: "Add a label to a bug.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLabelAdd(env, args) - }), - ValidArgsFunction: completeBugAndLabels(env, true), - } - - return cmd -} - -func runLabelAdd(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - added := args - - changes, _, err := b.ChangeLabels(text.CleanupOneLineArray(added), nil) - - for _, change := range changes { - env.out.Println(change) - } - - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/label_ls.go b/commands/label_ls.go deleted file mode 100644 index 242eb00c..00000000 --- a/commands/label_ls.go +++ /dev/null @@ -1,33 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" -) - -func newLabelLsCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "ls", - Short: "List valid labels.", - Long: `List valid labels. - -Note: in the future, a proper label policy could be implemented where valid labels are defined in a configuration file. Until that, the default behavior is to return the list of labels already used.`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLabelLs(env) - }), - } - - return cmd -} - -func runLabelLs(env *Env) error { - labels := env.backend.ValidLabels() - - for _, l := range labels { - env.out.Println(l) - } - - return nil -} diff --git a/commands/label_rm.go b/commands/label_rm.go deleted file mode 100644 index 3f4e1958..00000000 --- a/commands/label_rm.go +++ /dev/null @@ -1,45 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/util/text" -) - -func newLabelRmCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "rm [ID] LABEL...", - Short: "Remove a label from a bug.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLabelRm(env, args) - }), - ValidArgsFunction: completeBugAndLabels(env, false), - } - - return cmd -} - -func runLabelRm(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - removed := args - - changes, _, err := b.ChangeLabels(nil, text.CleanupOneLineArray(removed)) - - for _, change := range changes { - env.out.Println(change) - } - - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/ls-id.go b/commands/ls-id.go deleted file mode 100644 index 31107e87..00000000 --- a/commands/ls-id.go +++ /dev/null @@ -1,42 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" -) - -func newLsIdCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "ls-id [PREFIX]", - Short: "List bug identifiers.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLsId(env, args) - }), - Deprecated: `and will be removed in v1.0. - -Please use the "ls" command which allows filtering and sorting of the resulting -list of ids. The following example would print a new-line separated list containing -the ids of all open bugs: -git-bug ls --format id --status open -`, - } - - return cmd -} - -func runLsId(env *Env, args []string) error { - var prefix = "" - if len(args) != 0 { - prefix = args[0] - } - - for _, id := range env.backend.AllBugsIds() { - if prefix == "" || id.HasPrefix(prefix) { - env.out.Println(id) - } - } - - return nil -} diff --git a/commands/ls-labels.go b/commands/ls-labels.go deleted file mode 100644 index 00fc3fe6..00000000 --- a/commands/ls-labels.go +++ /dev/null @@ -1,29 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" -) - -func newLsLabelCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "ls-label", - Short: "List valid labels.", - Long: `List valid labels. - -Note: in the future, a proper label policy could be implemented where valid labels are defined in a configuration file. Until that, the default behavior is to return the list of labels already used.`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLabelLs(env) - }), - Deprecated: ` and will be removed in v1.0. - -The functionality provided by this command is now provided by -the following (equivalent) command: -git-bug label ls -`, - } - - return cmd -} diff --git a/commands/ls.go b/commands/ls.go deleted file mode 100644 index 7ed897fa..00000000 --- a/commands/ls.go +++ /dev/null @@ -1,446 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "time" - - text "github.com/MichaelMure/go-term-text" - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/entities/bug" - "github.com/MichaelMure/git-bug/entities/common" - "github.com/MichaelMure/git-bug/query" - "github.com/MichaelMure/git-bug/util/colors" -) - -type lsOptions struct { - statusQuery []string - authorQuery []string - metadataQuery []string - participantQuery []string - actorQuery []string - labelQuery []string - titleQuery []string - noQuery []string - sortBy string - sortDirection string - outputFormat string -} - -func newLsCommand() *cobra.Command { - env := newEnv() - options := lsOptions{} - - cmd := &cobra.Command{ - Use: "ls [QUERY]", - Short: "List bugs.", - Long: `Display a summary of each bugs. - -You can pass an additional query to filter and order the list. This query can be expressed either with a simple query language, flags, a natural language full text search, or a combination of the aforementioned.`, - Example: `List open bugs sorted by last edition with a query: -git bug ls status:open sort:edit-desc - -List closed bugs sorted by creation with flags: -git bug ls --status closed --by creation - -Do a full text search of all bugs: -git bug ls "foo bar" baz - -Use queries, flags, and full text search: -git bug ls status:open --by creation "foo bar" baz -`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runLs(env, options, args) - }), - ValidArgsFunction: completeLs(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringSliceVarP(&options.statusQuery, "status", "s", nil, - "Filter by status. Valid values are [open,closed]") - cmd.RegisterFlagCompletionFunc("status", completeFrom([]string{"open", "closed"})) - flags.StringSliceVarP(&options.authorQuery, "author", "a", nil, - "Filter by author") - flags.StringSliceVarP(&options.metadataQuery, "metadata", "m", nil, - "Filter by metadata. Example: github-url=URL") - cmd.RegisterFlagCompletionFunc("author", completeUserForQuery(env)) - flags.StringSliceVarP(&options.participantQuery, "participant", "p", nil, - "Filter by participant") - cmd.RegisterFlagCompletionFunc("participant", completeUserForQuery(env)) - flags.StringSliceVarP(&options.actorQuery, "actor", "A", nil, - "Filter by actor") - cmd.RegisterFlagCompletionFunc("actor", completeUserForQuery(env)) - flags.StringSliceVarP(&options.labelQuery, "label", "l", nil, - "Filter by label") - cmd.RegisterFlagCompletionFunc("label", completeLabel(env)) - flags.StringSliceVarP(&options.titleQuery, "title", "t", nil, - "Filter by title") - flags.StringSliceVarP(&options.noQuery, "no", "n", nil, - "Filter by absence of something. Valid values are [label]") - cmd.RegisterFlagCompletionFunc("no", completeLabel(env)) - flags.StringVarP(&options.sortBy, "by", "b", "creation", - "Sort the results by a characteristic. Valid values are [id,creation,edit]") - cmd.RegisterFlagCompletionFunc("by", completeFrom([]string{"id", "creation", "edit"})) - flags.StringVarP(&options.sortDirection, "direction", "d", "asc", - "Select the sorting direction. Valid values are [asc,desc]") - cmd.RegisterFlagCompletionFunc("direction", completeFrom([]string{"asc", "desc"})) - flags.StringVarP(&options.outputFormat, "format", "f", "default", - "Select the output formatting style. Valid values are [default,plain,compact,id,json,org-mode]") - cmd.RegisterFlagCompletionFunc("format", - completeFrom([]string{"default", "plain", "compact", "id", "json", "org-mode"})) - - return cmd -} - -func runLs(env *Env, opts lsOptions, args []string) error { - var q *query.Query - var err error - - if len(args) >= 1 { - // either the shell or cobra remove the quotes, we need them back for the query parsing - assembled := repairQuery(args) - - q, err = query.Parse(assembled) - if err != nil { - return err - } - } else { - q = query.NewQuery() - } - - err = completeQuery(q, opts) - if err != nil { - return err - } - - allIds, err := env.backend.QueryBugs(q) - if err != nil { - return err - } - - bugExcerpt := make([]*cache.BugExcerpt, len(allIds)) - for i, id := range allIds { - b, err := env.backend.ResolveBugExcerpt(id) - if err != nil { - return err - } - bugExcerpt[i] = b - } - - switch opts.outputFormat { - case "org-mode": - return lsOrgmodeFormatter(env, bugExcerpt) - case "plain": - return lsPlainFormatter(env, bugExcerpt) - case "json": - return lsJsonFormatter(env, bugExcerpt) - case "compact": - return lsCompactFormatter(env, bugExcerpt) - case "id": - return lsIDFormatter(env, bugExcerpt) - case "default": - return lsDefaultFormatter(env, bugExcerpt) - default: - return fmt.Errorf("unknown format %s", opts.outputFormat) - } -} - -func repairQuery(args []string) string { - for i, arg := range args { - split := strings.Split(arg, ":") - for j, s := range split { - if strings.Contains(s, " ") { - split[j] = fmt.Sprintf("\"%s\"", s) - } - } - args[i] = strings.Join(split, ":") - } - return strings.Join(args, " ") -} - -type JSONBugExcerpt struct { - Id string `json:"id"` - HumanId string `json:"human_id"` - CreateTime JSONTime `json:"create_time"` - EditTime JSONTime `json:"edit_time"` - - Status string `json:"status"` - Labels []bug.Label `json:"labels"` - Title string `json:"title"` - Actors []JSONIdentity `json:"actors"` - Participants []JSONIdentity `json:"participants"` - Author JSONIdentity `json:"author"` - - Comments int `json:"comments"` - Metadata map[string]string `json:"metadata"` -} - -func lsJsonFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - jsonBugs := make([]JSONBugExcerpt, len(bugExcerpts)) - for i, b := range bugExcerpts { - jsonBug := JSONBugExcerpt{ - Id: b.Id.String(), - HumanId: b.Id.Human(), - CreateTime: NewJSONTime(b.CreateTime(), b.CreateLamportTime), - EditTime: NewJSONTime(b.EditTime(), b.EditLamportTime), - Status: b.Status.String(), - Labels: b.Labels, - Title: b.Title, - Comments: b.LenComments, - Metadata: b.CreateMetadata, - } - - author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId) - if err != nil { - return err - } - jsonBug.Author = NewJSONIdentityFromExcerpt(author) - - jsonBug.Actors = make([]JSONIdentity, len(b.Actors)) - for i, element := range b.Actors { - actor, err := env.backend.ResolveIdentityExcerpt(element) - if err != nil { - return err - } - jsonBug.Actors[i] = NewJSONIdentityFromExcerpt(actor) - } - - jsonBug.Participants = make([]JSONIdentity, len(b.Participants)) - for i, element := range b.Participants { - participant, err := env.backend.ResolveIdentityExcerpt(element) - if err != nil { - return err - } - jsonBug.Participants[i] = NewJSONIdentityFromExcerpt(participant) - } - - jsonBugs[i] = jsonBug - } - jsonObject, _ := json.MarshalIndent(jsonBugs, "", " ") - env.out.Printf("%s\n", jsonObject) - return nil -} - -func lsCompactFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - for _, b := range bugExcerpts { - author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId) - if err != nil { - return err - } - - var labelsTxt strings.Builder - for _, l := range b.Labels { - lc256 := l.Color().Term256() - labelsTxt.WriteString(lc256.Escape()) - labelsTxt.WriteString("◼") - labelsTxt.WriteString(lc256.Unescape()) - } - - env.out.Printf("%s %s %s %s %s\n", - colors.Cyan(b.Id.Human()), - colors.Yellow(b.Status), - text.LeftPadMaxLine(strings.TrimSpace(b.Title), 40, 0), - text.LeftPadMaxLine(labelsTxt.String(), 5, 0), - colors.Magenta(text.TruncateMax(author.DisplayName(), 15)), - ) - } - return nil -} - -func lsIDFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - for _, b := range bugExcerpts { - env.out.Println(b.Id.String()) - } - - return nil -} - -func lsDefaultFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - for _, b := range bugExcerpts { - author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId) - if err != nil { - return err - } - - var labelsTxt strings.Builder - for _, l := range b.Labels { - lc256 := l.Color().Term256() - labelsTxt.WriteString(lc256.Escape()) - labelsTxt.WriteString(" ◼") - labelsTxt.WriteString(lc256.Unescape()) - } - - // truncate + pad if needed - labelsFmt := text.TruncateMax(labelsTxt.String(), 10) - titleFmt := text.LeftPadMaxLine(strings.TrimSpace(b.Title), 50-text.Len(labelsFmt), 0) - authorFmt := text.LeftPadMaxLine(author.DisplayName(), 15, 0) - - comments := fmt.Sprintf("%3d 💬", b.LenComments-1) - if b.LenComments-1 <= 0 { - comments = "" - } - if b.LenComments-1 > 999 { - comments = " ∞ 💬" - } - - env.out.Printf("%s\t%s\t%s\t%s\t%s\n", - colors.Cyan(b.Id.Human()), - colors.Yellow(b.Status), - titleFmt+labelsFmt, - colors.Magenta(authorFmt), - comments, - ) - } - return nil -} - -func lsPlainFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - for _, b := range bugExcerpts { - env.out.Printf("%s [%s] %s\n", b.Id.Human(), b.Status, strings.TrimSpace(b.Title)) - } - return nil -} - -func lsOrgmodeFormatter(env *Env, bugExcerpts []*cache.BugExcerpt) error { - // see https://orgmode.org/manual/Tags.html - orgTagRe := regexp.MustCompile("[^[:alpha:]_@]") - formatTag := func(l bug.Label) string { - return orgTagRe.ReplaceAllString(l.String(), "_") - } - - formatTime := func(time time.Time) string { - return time.Format("[2006-01-02 Mon 15:05]") - } - - env.out.Println("#+TODO: OPEN | CLOSED") - - for _, b := range bugExcerpts { - status := strings.ToUpper(b.Status.String()) - - var title string - if link, ok := b.CreateMetadata["github-url"]; ok { - title = fmt.Sprintf("[[%s][%s]]", link, b.Title) - } else { - title = b.Title - } - - author, err := env.backend.ResolveIdentityExcerpt(b.AuthorId) - if err != nil { - return err - } - - var labels strings.Builder - labels.WriteString(":") - for i, l := range b.Labels { - if i > 0 { - labels.WriteString(":") - } - labels.WriteString(formatTag(l)) - } - labels.WriteString(":") - - env.out.Printf("* %-6s %s %s %s: %s %s\n", - status, - b.Id.Human(), - formatTime(b.CreateTime()), - author.DisplayName(), - title, - labels.String(), - ) - - env.out.Printf("** Last Edited: %s\n", formatTime(b.EditTime())) - - env.out.Printf("** Actors:\n") - for _, element := range b.Actors { - actor, err := env.backend.ResolveIdentityExcerpt(element) - if err != nil { - return err - } - - env.out.Printf(": %s %s\n", - actor.Id.Human(), - actor.DisplayName(), - ) - } - - env.out.Printf("** Participants:\n") - for _, element := range b.Participants { - participant, err := env.backend.ResolveIdentityExcerpt(element) - if err != nil { - return err - } - - env.out.Printf(": %s %s\n", - participant.Id.Human(), - participant.DisplayName(), - ) - } - } - - return nil -} - -// Finish the command flags transformation into the query.Query -func completeQuery(q *query.Query, opts lsOptions) error { - for _, str := range opts.statusQuery { - status, err := common.StatusFromString(str) - if err != nil { - return err - } - q.Status = append(q.Status, status) - } - - q.Author = append(q.Author, opts.authorQuery...) - for _, str := range opts.metadataQuery { - tokens := strings.Split(str, "=") - if len(tokens) < 2 { - return fmt.Errorf("no \"=\" in key=value metadata markup") - } - var pair query.StringPair - pair.Key = tokens[0] - pair.Value = tokens[1] - q.Metadata = append(q.Metadata, pair) - } - q.Participant = append(q.Participant, opts.participantQuery...) - q.Actor = append(q.Actor, opts.actorQuery...) - q.Label = append(q.Label, opts.labelQuery...) - q.Title = append(q.Title, opts.titleQuery...) - - for _, no := range opts.noQuery { - switch no { - case "label": - q.NoLabel = true - default: - return fmt.Errorf("unknown \"no\" filter %s", no) - } - } - - switch opts.sortBy { - case "id": - q.OrderBy = query.OrderById - case "creation": - q.OrderBy = query.OrderByCreation - case "edit": - q.OrderBy = query.OrderByEdit - default: - return fmt.Errorf("unknown sort flag %s", opts.sortBy) - } - - switch opts.sortDirection { - case "asc": - q.OrderDirection = query.OrderAscending - case "desc": - q.OrderDirection = query.OrderDescending - default: - return fmt.Errorf("unknown sort direction %s", opts.sortDirection) - } - - return nil -} diff --git a/commands/ls_test.go b/commands/ls_test.go deleted file mode 100644 index 22adc1ce..00000000 --- a/commands/ls_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - "testing" - - "github.com/stretchr/testify/require" -) - -func Test_repairQuery(t *testing.T) { - cases := []struct { - args []string - output string - }{ - { - []string{""}, - "", - }, - { - []string{"foo"}, - "foo", - }, - { - []string{"foo", "bar"}, - "foo bar", - }, - { - []string{"foo bar", "baz"}, - "\"foo bar\" baz", - }, - { - []string{"foo:bar", "baz"}, - "foo:bar baz", - }, - { - []string{"foo:bar boo", "baz"}, - "foo:\"bar boo\" baz", - }, - } - - for _, tc := range cases { - require.Equal(t, tc.output, repairQuery(tc.args)) - } -} - -func TestLs_Format(t *testing.T) { - const expOrgMode = `^#+TODO: OPEN | CLOSED -[*] OPEN [0-9a-f]{7} \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\] John Doe: this is a bug title :: -[*]{2} Last Edited: \[\d\d\d\d-\d\d-\d\d [[:alpha:]]{3} \d\d:\d\d\] -[*]{2} Actors: -: [0-9a-f]{7} John Doe -[*]{2} Participants: -: [0-9a-f]{7} John Doe -$` - - cases := []struct { - format string - exp string - }{ - {"default", "^[0-9a-f]{7}\topen\tthis is a bug title \tJohn Doe \t\n$"}, - {"plain", "^[0-9a-f]{7} \\[open\\] this is a bug title\n$"}, - {"compact", "^[0-9a-f]{7} open this is a bug title John Doe\n$"}, - {"id", "^[0-9a-f]{64}\n$"}, - {"org-mode", expOrgMode}, - } - - for _, testcase := range cases { - opts := lsOptions{ - sortDirection: "asc", - sortBy: "creation", - outputFormat: testcase.format, - } - - name := fmt.Sprintf("with %s format", testcase.format) - - t.Run(name, func(t *testing.T) { - env, _ := newTestEnvAndBug(t) - - require.NoError(t, runLs(env.env, opts, []string{})) - require.Regexp(t, testcase.exp, env.out.String()) - }) - } - - t.Run("with JSON format", func(t *testing.T) { - opts := lsOptions{ - sortDirection: "asc", - sortBy: "creation", - outputFormat: "json", - } - - env, _ := newTestEnvAndBug(t) - - require.NoError(t, runLs(env.env, opts, []string{})) - - bugs := []JSONBugExcerpt{} - require.NoError(t, json.Unmarshal(env.out.Bytes(), &bugs)) - - require.Len(t, bugs, 1) - }) -} diff --git a/commands/pull.go b/commands/pull.go index 29c9f034..2e2639e1 100644 --- a/commands/pull.go +++ b/commands/pull.go @@ -5,26 +5,28 @@ import ( "github.com/spf13/cobra" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" "github.com/MichaelMure/git-bug/entity" ) func newPullCommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() cmd := &cobra.Command{ Use: "pull [REMOTE]", - Short: "Pull bugs update from a git remote.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { + Short: "Pull updates from a git remote", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { return runPull(env, args) }), - ValidArgsFunction: completeGitRemote(env), + ValidArgsFunction: completion.GitRemote(env), } return cmd } -func runPull(env *Env, args []string) error { +func runPull(env *execenv.Env, args []string) error { if len(args) > 1 { return errors.New("Only pulling from one remote at a time is supported") } @@ -34,24 +36,24 @@ func runPull(env *Env, args []string) error { remote = args[0] } - env.out.Println("Fetching remote ...") + env.Out.Println("Fetching remote ...") - stdout, err := env.backend.Fetch(remote) + stdout, err := env.Backend.Fetch(remote) if err != nil { return err } - env.out.Println(stdout) + env.Out.Println(stdout) - env.out.Println("Merging data ...") + env.Out.Println("Merging data ...") - for result := range env.backend.MergeAll(remote) { + for result := range env.Backend.MergeAll(remote) { if result.Err != nil { - env.err.Println(result.Err) + env.Err.Println(result.Err) } if result.Status != entity.MergeStatusNothing { - env.out.Printf("%s: %s\n", result.Id.Human(), result) + env.Out.Printf("%s: %s\n", result.Id.Human(), result) } } diff --git a/commands/push.go b/commands/push.go index adba6bef..d45e301a 100644 --- a/commands/push.go +++ b/commands/push.go @@ -4,25 +4,28 @@ import ( "errors" "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" ) func newPushCommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() cmd := &cobra.Command{ Use: "push [REMOTE]", - Short: "Push bugs update to a git remote.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { + Short: "Push updates to a git remote", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { return runPush(env, args) }), - ValidArgsFunction: completeGitRemote(env), + ValidArgsFunction: completion.GitRemote(env), } return cmd } -func runPush(env *Env, args []string) error { +func runPush(env *execenv.Env, args []string) error { if len(args) > 1 { return errors.New("Only pushing to one remote at a time is supported") } @@ -32,12 +35,12 @@ func runPush(env *Env, args []string) error { remote = args[0] } - stdout, err := env.backend.Push(remote) + stdout, err := env.Backend.Push(remote) if err != nil { return err } - env.out.Println(stdout) + env.Out.Println(stdout) return nil } diff --git a/commands/rm.go b/commands/rm.go deleted file mode 100644 index 2e1d924d..00000000 --- a/commands/rm.go +++ /dev/null @@ -1,43 +0,0 @@ -package commands - -import ( - "errors" - - "github.com/spf13/cobra" -) - -func newRmCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "rm ID", - Short: "Remove an existing bug.", - Long: "Remove an existing bug in the local repository. Note removing bugs that were imported from bridges will not remove the bug on the remote, and will only remove the local copy of the bug.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runRm(env, args) - }), - ValidArgsFunction: completeBug(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - return cmd -} - -func runRm(env *Env, args []string) (err error) { - if len(args) == 0 { - return errors.New("you must provide a bug prefix to remove") - } - - err = env.backend.RemoveBug(args[0]) - - if err != nil { - return - } - - env.out.Printf("bug %s removed\n", args[0]) - - return -} diff --git a/commands/rm_test.go b/commands/rm_test.go deleted file mode 100644 index 0156bbd4..00000000 --- a/commands/rm_test.go +++ /dev/null @@ -1,17 +0,0 @@ -package commands - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestRm(t *testing.T) { - testEnv, bugID := newTestEnvAndBug(t) - - exp := "bug " + bugID + " removed\n" - - require.NoError(t, runRm(testEnv.env, []string{bugID})) - require.Equal(t, exp, testEnv.out.String()) - testEnv.out.Reset() -} diff --git a/commands/root.go b/commands/root.go index e012bd83..b28b77b8 100644 --- a/commands/root.go +++ b/commands/root.go @@ -6,9 +6,13 @@ import ( "os" "github.com/spf13/cobra" -) -const rootCommandName = "git-bug" + "github.com/MichaelMure/git-bug/commands/bridge" + usercmd "github.com/MichaelMure/git-bug/commands/user" + + "github.com/MichaelMure/git-bug/commands/bug" + "github.com/MichaelMure/git-bug/commands/execenv" +) // These variables are initialized externally during the build. See the Makefile. var GitCommit string @@ -17,8 +21,8 @@ var GitExactTag string func NewRootCommand() *cobra.Command { cmd := &cobra.Command{ - Use: rootCommandName, - Short: "A bug tracker embedded in Git.", + Use: execenv.RootCommandName, + Short: "A bug tracker embedded in Git", Long: `git-bug is a bug tracker embedded in git. git-bug use git objects to store the bug tracking separated from the files @@ -52,26 +56,32 @@ the same git remote you are already using to collaborate with other people. DisableAutoGenTag: true, } - cmd.AddCommand(newAddCommand()) - cmd.AddCommand(newBridgeCommand()) + const entityGroup = "entity" + const uiGroup = "ui" + const remoteGroup = "remote" + + cmd.AddGroup(&cobra.Group{ID: entityGroup, Title: "Entities"}) + cmd.AddGroup(&cobra.Group{ID: uiGroup, Title: "User interfaces"}) + cmd.AddGroup(&cobra.Group{ID: remoteGroup, Title: "Interaction with the outside world"}) + + addCmdWithGroup := func(child *cobra.Command, groupID string) { + cmd.AddCommand(child) + child.GroupID = groupID + } + + addCmdWithGroup(bugcmd.NewBugCommand(), entityGroup) + addCmdWithGroup(usercmd.NewUserCommand(), entityGroup) + addCmdWithGroup(newLabelCommand(), entityGroup) + + addCmdWithGroup(newTermUICommand(), uiGroup) + addCmdWithGroup(newWebUICommand(), uiGroup) + + addCmdWithGroup(newPullCommand(), remoteGroup) + addCmdWithGroup(newPushCommand(), remoteGroup) + addCmdWithGroup(bridgecmd.NewBridgeCommand(), remoteGroup) + cmd.AddCommand(newCommandsCommand()) - cmd.AddCommand(newCommentCommand()) - cmd.AddCommand(newDeselectCommand()) - cmd.AddCommand(newLabelCommand()) - cmd.AddCommand(newLsCommand()) - cmd.AddCommand(newLsIdCommand()) - cmd.AddCommand(newLsLabelCommand()) - cmd.AddCommand(newPullCommand()) - cmd.AddCommand(newPushCommand()) - cmd.AddCommand(newRmCommand()) - cmd.AddCommand(newSelectCommand()) - cmd.AddCommand(newShowCommand()) - cmd.AddCommand(newStatusCommand()) - cmd.AddCommand(newTermUICommand()) - cmd.AddCommand(newTitleCommand()) - cmd.AddCommand(newUserCommand()) cmd.AddCommand(newVersionCommand()) - cmd.AddCommand(newWebUICommand()) return cmd } diff --git a/commands/select.go b/commands/select.go deleted file mode 100644 index f9e6ece7..00000000 --- a/commands/select.go +++ /dev/null @@ -1,60 +0,0 @@ -package commands - -import ( - "errors" - - "github.com/spf13/cobra" - - _select "github.com/MichaelMure/git-bug/commands/select" -) - -func newSelectCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "select ID", - Short: "Select a bug for implicit use in future commands.", - Example: `git bug select 2f15 -git bug comment -git bug status -`, - Long: `Select a bug for implicit use in future commands. - -This command allows you to omit any bug ID argument, for example: - git bug show -instead of - git bug show 2f153ca - -The complementary command is "git bug deselect" performing the opposite operation. -`, - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runSelect(env, args) - }), - ValidArgsFunction: completeBug(env), - } - - return cmd -} - -func runSelect(env *Env, args []string) error { - if len(args) == 0 { - return errors.New("You must provide a bug id") - } - - prefix := args[0] - - b, err := env.backend.ResolveBugPrefix(prefix) - if err != nil { - return err - } - - err = _select.Select(env.backend, b.Id()) - if err != nil { - return err - } - - env.out.Printf("selected bug %s: %s\n", b.Id().Human(), b.Snapshot().Title) - - return nil -} diff --git a/commands/select/select.go b/commands/select/select.go deleted file mode 100644 index 908ad58c..00000000 --- a/commands/select/select.go +++ /dev/null @@ -1,129 +0,0 @@ -package _select - -import ( - "fmt" - "io" - "io/ioutil" - "os" - - "github.com/pkg/errors" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/entities/bug" - "github.com/MichaelMure/git-bug/entity" -) - -const selectFile = "select" - -var ErrNoValidId = errors.New("you must provide a bug id or use the \"select\" command first") - -// ResolveBug first try to resolve a bug using the first argument of the command -// line. If it fails, it fallback to the select mechanism. -// -// Returns: -// - the bug if any -// - the new list of command line arguments with the bug prefix removed if it -// has been used -// - an error if the process failed -func ResolveBug(repo *cache.RepoCache, args []string) (*cache.BugCache, []string, error) { - // At first, try to use the first argument as a bug prefix - if len(args) > 0 { - b, err := repo.ResolveBugPrefix(args[0]) - - if err == nil { - return b, args[1:], nil - } - - if err != bug.ErrBugNotExist { - return nil, nil, err - } - } - - // first arg is not a valid bug prefix, we can safely use the preselected bug if any - - b, err := selected(repo) - - // selected bug is invalid - if err == bug.ErrBugNotExist { - // we clear the selected bug - err = Clear(repo) - if err != nil { - return nil, nil, err - } - return nil, nil, ErrNoValidId - } - - // another error when reading the bug - if err != nil { - return nil, nil, err - } - - // bug is successfully retrieved - if b != nil { - return b, args, nil - } - - // no selected bug and no valid first argument - return nil, nil, ErrNoValidId -} - -// Select will select a bug for future use -func Select(repo *cache.RepoCache, id entity.Id) error { - f, err := repo.LocalStorage().OpenFile(selectFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0666) - if err != nil { - return err - } - - _, err = f.Write([]byte(id.String())) - if err != nil { - return err - } - - return f.Close() -} - -// Clear will clear the selected bug, if any -func Clear(repo *cache.RepoCache) error { - return repo.LocalStorage().Remove(selectFile) -} - -func selected(repo *cache.RepoCache) (*cache.BugCache, error) { - f, err := repo.LocalStorage().Open(selectFile) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } else { - return nil, err - } - } - - buf, err := ioutil.ReadAll(io.LimitReader(f, 100)) - if err != nil { - return nil, err - } - if len(buf) == 100 { - return nil, fmt.Errorf("the select file should be < 100 bytes") - } - - id := entity.Id(buf) - if err := id.Validate(); err != nil { - err = repo.LocalStorage().Remove(selectFile) - if err != nil { - return nil, errors.Wrap(err, "error while removing invalid select file") - } - - return nil, fmt.Errorf("select file in invalid, removing it") - } - - b, err := repo.ResolveBug(id) - if err != nil { - return nil, err - } - - err = f.Close() - if err != nil { - return nil, err - } - - return b, nil -} diff --git a/commands/select/select_test.go b/commands/select/select_test.go deleted file mode 100644 index 702700f4..00000000 --- a/commands/select/select_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package _select - -import ( - "testing" - "time" - - "github.com/stretchr/testify/require" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/repository" -) - -func TestSelect(t *testing.T) { - repo := repository.CreateGoGitTestRepo(t, false) - - repoCache, err := cache.NewRepoCache(repo) - require.NoError(t, err) - - _, _, err = ResolveBug(repoCache, []string{}) - require.Equal(t, ErrNoValidId, err) - - err = Select(repoCache, "invalid") - require.NoError(t, err) - - // Resolve without a pattern should fail when no bug is selected - _, _, err = ResolveBug(repoCache, []string{}) - require.Error(t, err) - - // generate a bunch of bugs - - rene, err := repoCache.NewIdentity("René Descartes", "rene@descartes.fr") - require.NoError(t, err) - - for i := 0; i < 10; i++ { - _, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) - require.NoError(t, err) - } - - // and two more for testing - b1, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) - require.NoError(t, err) - b2, _, err := repoCache.NewBugRaw(rene, time.Now().Unix(), "title", "message", nil, nil) - require.NoError(t, err) - - err = Select(repoCache, b1.Id()) - require.NoError(t, err) - - // normal select without args - b3, _, err := ResolveBug(repoCache, []string{}) - require.NoError(t, err) - require.Equal(t, b1.Id(), b3.Id()) - - // override selection with same id - b4, _, err := ResolveBug(repoCache, []string{b1.Id().String()}) - require.NoError(t, err) - require.Equal(t, b1.Id(), b4.Id()) - - // override selection with a prefix - b5, _, err := ResolveBug(repoCache, []string{b1.Id().Human()}) - require.NoError(t, err) - require.Equal(t, b1.Id(), b5.Id()) - - // args that shouldn't override - b6, _, err := ResolveBug(repoCache, []string{"arg"}) - require.NoError(t, err) - require.Equal(t, b1.Id(), b6.Id()) - - // override with a different id - b7, _, err := ResolveBug(repoCache, []string{b2.Id().String()}) - require.NoError(t, err) - require.Equal(t, b2.Id(), b7.Id()) - - err = Clear(repoCache) - require.NoError(t, err) - - // Resolve without a pattern should error again after clearing the selected bug - _, _, err = ResolveBug(repoCache, []string{}) - require.Error(t, err) -} diff --git a/commands/show.go b/commands/show.go deleted file mode 100644 index 1491372e..00000000 --- a/commands/show.go +++ /dev/null @@ -1,326 +0,0 @@ -package commands - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/entities/bug" - "github.com/MichaelMure/git-bug/util/colors" -) - -type showOptions struct { - fields string - format string -} - -func newShowCommand() *cobra.Command { - env := newEnv() - options := showOptions{} - - cmd := &cobra.Command{ - Use: "show [ID]", - Short: "Display the details of a bug.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runShow(env, options, args) - }), - ValidArgsFunction: completeBug(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - fields := []string{"author", "authorEmail", "createTime", "lastEdit", "humanId", - "id", "labels", "shortId", "status", "title", "actors", "participants"} - flags.StringVarP(&options.fields, "field", "", "", - "Select field to display. Valid values are ["+strings.Join(fields, ",")+"]") - cmd.RegisterFlagCompletionFunc("by", completeFrom(fields)) - flags.StringVarP(&options.format, "format", "f", "default", - "Select the output formatting style. Valid values are [default,json,org-mode]") - - return cmd -} - -func runShow(env *Env, opts showOptions, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() - - if len(snap.Comments) == 0 { - return errors.New("invalid bug: no comment") - } - - if opts.fields != "" { - switch opts.fields { - case "author": - env.out.Printf("%s\n", snap.Author.DisplayName()) - case "authorEmail": - env.out.Printf("%s\n", snap.Author.Email()) - case "createTime": - env.out.Printf("%s\n", snap.CreateTime.String()) - case "lastEdit": - env.out.Printf("%s\n", snap.EditTime().String()) - case "humanId": - env.out.Printf("%s\n", snap.Id().Human()) - case "id": - env.out.Printf("%s\n", snap.Id()) - case "labels": - for _, l := range snap.Labels { - env.out.Printf("%s\n", l.String()) - } - case "actors": - for _, a := range snap.Actors { - env.out.Printf("%s\n", a.DisplayName()) - } - case "participants": - for _, p := range snap.Participants { - env.out.Printf("%s\n", p.DisplayName()) - } - case "shortId": - env.out.Printf("%s\n", snap.Id().Human()) - case "status": - env.out.Printf("%s\n", snap.Status) - case "title": - env.out.Printf("%s\n", snap.Title) - default: - return fmt.Errorf("\nUnsupported field: %s\n", opts.fields) - } - - return nil - } - - switch opts.format { - case "org-mode": - return showOrgModeFormatter(env, snap) - case "json": - return showJsonFormatter(env, snap) - case "default": - return showDefaultFormatter(env, snap) - default: - return fmt.Errorf("unknown format %s", opts.format) - } -} - -func showDefaultFormatter(env *Env, snapshot *bug.Snapshot) error { - // Header - env.out.Printf("%s [%s] %s\n\n", - colors.Cyan(snapshot.Id().Human()), - colors.Yellow(snapshot.Status), - snapshot.Title, - ) - - env.out.Printf("%s opened this issue %s\n", - colors.Magenta(snapshot.Author.DisplayName()), - snapshot.CreateTime.String(), - ) - - env.out.Printf("This was last edited at %s\n\n", - snapshot.EditTime().String(), - ) - - // Labels - var labels = make([]string, len(snapshot.Labels)) - for i := range snapshot.Labels { - labels[i] = string(snapshot.Labels[i]) - } - - env.out.Printf("labels: %s\n", - strings.Join(labels, ", "), - ) - - // Actors - var actors = make([]string, len(snapshot.Actors)) - for i := range snapshot.Actors { - actors[i] = snapshot.Actors[i].DisplayName() - } - - env.out.Printf("actors: %s\n", - strings.Join(actors, ", "), - ) - - // Participants - var participants = make([]string, len(snapshot.Participants)) - for i := range snapshot.Participants { - participants[i] = snapshot.Participants[i].DisplayName() - } - - env.out.Printf("participants: %s\n\n", - strings.Join(participants, ", "), - ) - - // Comments - indent := " " - - for i, comment := range snapshot.Comments { - var message string - env.out.Printf("%s%s #%d %s <%s>\n\n", - indent, - comment.CombinedId().Human(), - i, - comment.Author.DisplayName(), - comment.Author.Email(), - ) - - if comment.Message == "" { - message = colors.BlackBold(colors.WhiteBg("No description provided.")) - } else { - message = comment.Message - } - - env.out.Printf("%s%s\n\n\n", - indent, - message, - ) - } - - return nil -} - -type JSONBugSnapshot struct { - Id string `json:"id"` - HumanId string `json:"human_id"` - CreateTime JSONTime `json:"create_time"` - EditTime JSONTime `json:"edit_time"` - Status string `json:"status"` - Labels []bug.Label `json:"labels"` - Title string `json:"title"` - Author JSONIdentity `json:"author"` - Actors []JSONIdentity `json:"actors"` - Participants []JSONIdentity `json:"participants"` - Comments []JSONComment `json:"comments"` -} - -type JSONComment struct { - Id string `json:"id"` - HumanId string `json:"human_id"` - Author JSONIdentity `json:"author"` - Message string `json:"message"` -} - -func NewJSONComment(comment bug.Comment) JSONComment { - return JSONComment{ - Id: comment.CombinedId().String(), - HumanId: comment.CombinedId().Human(), - Author: NewJSONIdentity(comment.Author), - Message: comment.Message, - } -} - -func showJsonFormatter(env *Env, snapshot *bug.Snapshot) error { - jsonBug := JSONBugSnapshot{ - Id: snapshot.Id().String(), - HumanId: snapshot.Id().Human(), - CreateTime: NewJSONTime(snapshot.CreateTime, 0), - EditTime: NewJSONTime(snapshot.EditTime(), 0), - Status: snapshot.Status.String(), - Labels: snapshot.Labels, - Title: snapshot.Title, - Author: NewJSONIdentity(snapshot.Author), - } - - jsonBug.Actors = make([]JSONIdentity, len(snapshot.Actors)) - for i, element := range snapshot.Actors { - jsonBug.Actors[i] = NewJSONIdentity(element) - } - - jsonBug.Participants = make([]JSONIdentity, len(snapshot.Participants)) - for i, element := range snapshot.Participants { - jsonBug.Participants[i] = NewJSONIdentity(element) - } - - jsonBug.Comments = make([]JSONComment, len(snapshot.Comments)) - for i, comment := range snapshot.Comments { - jsonBug.Comments[i] = NewJSONComment(comment) - } - - jsonObject, _ := json.MarshalIndent(jsonBug, "", " ") - env.out.Printf("%s\n", jsonObject) - - return nil -} - -func showOrgModeFormatter(env *Env, snapshot *bug.Snapshot) error { - // Header - env.out.Printf("%s [%s] %s\n", - snapshot.Id().Human(), - snapshot.Status, - snapshot.Title, - ) - - env.out.Printf("* Author: %s\n", - snapshot.Author.DisplayName(), - ) - - env.out.Printf("* Creation Time: %s\n", - snapshot.CreateTime.String(), - ) - - env.out.Printf("* Last Edit: %s\n", - snapshot.EditTime().String(), - ) - - // Labels - var labels = make([]string, len(snapshot.Labels)) - for i, label := range snapshot.Labels { - labels[i] = string(label) - } - - env.out.Printf("* Labels:\n") - if len(labels) > 0 { - env.out.Printf("** %s\n", - strings.Join(labels, "\n** "), - ) - } - - // Actors - var actors = make([]string, len(snapshot.Actors)) - for i, actor := range snapshot.Actors { - actors[i] = fmt.Sprintf("%s %s", - actor.Id().Human(), - actor.DisplayName(), - ) - } - - env.out.Printf("* Actors:\n** %s\n", - strings.Join(actors, "\n** "), - ) - - // Participants - var participants = make([]string, len(snapshot.Participants)) - for i, participant := range snapshot.Participants { - participants[i] = fmt.Sprintf("%s %s", - participant.Id().Human(), - participant.DisplayName(), - ) - } - - env.out.Printf("* Participants:\n** %s\n", - strings.Join(participants, "\n** "), - ) - - env.out.Printf("* Comments:\n") - - for i, comment := range snapshot.Comments { - var message string - env.out.Printf("** #%d %s\n", - i, comment.Author.DisplayName()) - - if comment.Message == "" { - message = "No description provided." - } else { - message = strings.ReplaceAll(comment.Message, "\n", "\n: ") - } - - env.out.Printf(": %s\n", message) - } - - return nil -} diff --git a/commands/status.go b/commands/status.go deleted file mode 100644 index c3e860b6..00000000 --- a/commands/status.go +++ /dev/null @@ -1,38 +0,0 @@ -package commands - -import ( - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/spf13/cobra" -) - -func newStatusCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "status [ID]", - Short: "Display or change a bug status.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runStatus(env, args) - }), - ValidArgsFunction: completeBug(env), - } - - cmd.AddCommand(newStatusCloseCommand()) - cmd.AddCommand(newStatusOpenCommand()) - - return cmd -} - -func runStatus(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() - - env.out.Println(snap.Status) - - return nil -} diff --git a/commands/status_close.go b/commands/status_close.go deleted file mode 100644 index 8541aa0b..00000000 --- a/commands/status_close.go +++ /dev/null @@ -1,35 +0,0 @@ -package commands - -import ( - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/spf13/cobra" -) - -func newStatusCloseCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "close [ID]", - Short: "Mark a bug as closed.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runStatusClose(env, args) - }), - } - - return cmd -} - -func runStatusClose(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - _, err = b.Close() - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/status_open.go b/commands/status_open.go deleted file mode 100644 index ee6bd27a..00000000 --- a/commands/status_open.go +++ /dev/null @@ -1,35 +0,0 @@ -package commands - -import ( - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/spf13/cobra" -) - -func newStatusOpenCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "open [ID]", - Short: "Mark a bug as open.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runStatusOpen(env, args) - }), - } - - return cmd -} - -func runStatusOpen(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - _, err = b.Open() - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/termui.go b/commands/termui.go index 4df6cdaf..1cfdd8f3 100644 --- a/commands/termui.go +++ b/commands/termui.go @@ -3,18 +3,19 @@ package commands import ( "github.com/spf13/cobra" + "github.com/MichaelMure/git-bug/commands/execenv" "github.com/MichaelMure/git-bug/termui" ) func newTermUICommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() cmd := &cobra.Command{ Use: "termui", Aliases: []string{"tui"}, - Short: "Launch the terminal UI.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { + Short: "Launch the terminal UI", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { return runTermUI(env) }), } @@ -22,6 +23,6 @@ func newTermUICommand() *cobra.Command { return cmd } -func runTermUI(env *Env) error { - return termui.Run(env.backend) +func runTermUI(env *execenv.Env) error { + return termui.Run(env.Backend) } diff --git a/commands/testdata/comment/add-0-golden.txt b/commands/testdata/comment/add-0-golden.txt deleted file mode 100644 index 44ae0c1a..00000000 --- a/commands/testdata/comment/add-0-golden.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - this is a bug message diff --git a/commands/testdata/comment/add-1-golden.txt b/commands/testdata/comment/add-1-golden.txt deleted file mode 100644 index bcf127c0..00000000 --- a/commands/testdata/comment/add-1-golden.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - this is a bug message - - - this is a bug comment diff --git a/commands/testdata/comment/edit-0-golden.txt b/commands/testdata/comment/edit-0-golden.txt deleted file mode 100644 index 44ae0c1a..00000000 --- a/commands/testdata/comment/edit-0-golden.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - this is a bug message diff --git a/commands/testdata/comment/edit-1-golden.txt b/commands/testdata/comment/edit-1-golden.txt deleted file mode 100644 index 3d83c02b..00000000 --- a/commands/testdata/comment/edit-1-golden.txt +++ /dev/null @@ -1,6 +0,0 @@ - - - this is a bug message - - - this is an altered bug comment diff --git a/commands/testdata/comment/message-only-0-golden.txt b/commands/testdata/comment/message-only-0-golden.txt deleted file mode 100644 index 44ae0c1a..00000000 --- a/commands/testdata/comment/message-only-0-golden.txt +++ /dev/null @@ -1,3 +0,0 @@ - - - this is a bug message diff --git a/commands/title.go b/commands/title.go deleted file mode 100644 index f99c6eff..00000000 --- a/commands/title.go +++ /dev/null @@ -1,37 +0,0 @@ -package commands - -import ( - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/spf13/cobra" -) - -func newTitleCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "title [ID]", - Short: "Display or change a title of a bug.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runTitle(env, args) - }), - ValidArgsFunction: completeBug(env), - } - - cmd.AddCommand(newTitleEditCommand()) - - return cmd -} - -func runTitle(env *Env, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() - - env.out.Println(snap.Title) - - return nil -} diff --git a/commands/title_edit.go b/commands/title_edit.go deleted file mode 100644 index a1ba0324..00000000 --- a/commands/title_edit.go +++ /dev/null @@ -1,74 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/input" - _select "github.com/MichaelMure/git-bug/commands/select" - "github.com/MichaelMure/git-bug/util/text" -) - -type titleEditOptions struct { - title string - nonInteractive bool -} - -func newTitleEditCommand() *cobra.Command { - env := newEnv() - options := titleEditOptions{} - - cmd := &cobra.Command{ - Use: "edit [ID]", - Short: "Edit a title of a bug.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runTitleEdit(env, options, args) - }), - ValidArgsFunction: completeBug(env), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.title, "title", "t", "", - "Provide a title to describe the issue", - ) - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runTitleEdit(env *Env, opts titleEditOptions, args []string) error { - b, args, err := _select.ResolveBug(env.backend, args) - if err != nil { - return err - } - - snap := b.Snapshot() - - if opts.title == "" { - if opts.nonInteractive { - env.err.Println("No title given. Use -m or -F option to specify a title. Aborting.") - return nil - } - opts.title, err = input.BugTitleEditorInput(env.repo, snap.Title) - if err == input.ErrEmptyTitle { - env.out.Println("Empty title, aborting.") - return nil - } - if err != nil { - return err - } - } - - if opts.title == snap.Title { - env.err.Println("No change, aborting.") - } - - _, err = b.SetTitle(text.CleanupOneLine(opts.title)) - if err != nil { - return err - } - - return b.Commit() -} diff --git a/commands/user.go b/commands/user.go deleted file mode 100644 index 0fe3be4d..00000000 --- a/commands/user.go +++ /dev/null @@ -1,110 +0,0 @@ -package commands - -import ( - "errors" - "fmt" - "strings" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/cache" -) - -type userOptions struct { - fields string -} - -func newUserCommand() *cobra.Command { - env := newEnv() - options := userOptions{} - - cmd := &cobra.Command{ - Use: "user [USER-ID]", - Short: "Display or change the user identity.", - PreRunE: loadBackendEnsureUser(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runUser(env, options, args) - }), - ValidArgsFunction: completeUser(env), - } - - cmd.AddCommand(newUserAdoptCommand()) - cmd.AddCommand(newUserCreateCommand()) - cmd.AddCommand(newUserLsCommand()) - - flags := cmd.Flags() - flags.SortFlags = false - - fields := []string{"email", "humanId", "id", "lastModification", "lastModificationLamports", "login", "metadata", "name"} - flags.StringVarP(&options.fields, "field", "f", "", - "Select field to display. Valid values are ["+strings.Join(fields, ",")+"]") - cmd.RegisterFlagCompletionFunc("field", completeFrom(fields)) - - return cmd -} - -func runUser(env *Env, opts userOptions, args []string) error { - if len(args) > 1 { - return errors.New("only one identity can be displayed at a time") - } - - var id *cache.IdentityCache - var err error - if len(args) == 1 { - id, err = env.backend.ResolveIdentityPrefix(args[0]) - } else { - id, err = env.backend.GetUserIdentity() - } - - if err != nil { - return err - } - - if opts.fields != "" { - switch opts.fields { - case "email": - env.out.Printf("%s\n", id.Email()) - case "login": - env.out.Printf("%s\n", id.Login()) - case "humanId": - env.out.Printf("%s\n", id.Id().Human()) - case "id": - env.out.Printf("%s\n", id.Id()) - case "lastModification": - env.out.Printf("%s\n", id.LastModification(). - Time().Format("Mon Jan 2 15:04:05 2006 +0200")) - case "lastModificationLamport": - for name, t := range id.LastModificationLamports() { - env.out.Printf("%s\n%d\n", name, t) - } - case "metadata": - for key, value := range id.ImmutableMetadata() { - env.out.Printf("%s\n%s\n", key, value) - } - case "name": - env.out.Printf("%s\n", id.Name()) - - default: - return fmt.Errorf("\nUnsupported field: %s\n", opts.fields) - } - - return nil - } - - env.out.Printf("Id: %s\n", id.Id()) - env.out.Printf("Name: %s\n", id.Name()) - env.out.Printf("Email: %s\n", id.Email()) - env.out.Printf("Login: %s\n", id.Login()) - env.out.Printf("Last modification: %s\n", id.LastModification().Time().Format("Mon Jan 2 15:04:05 2006 +0200")) - env.out.Printf("Last moditication (lamport):\n") - for name, t := range id.LastModificationLamports() { - env.out.Printf("\t%s: %d", name, t) - } - env.out.Println("Metadata:") - for key, value := range id.ImmutableMetadata() { - env.out.Printf(" %s --> %s\n", key, value) - } - // env.out.Printf("Protected: %v\n", id.IsProtected()) - - return nil -} diff --git a/commands/user/user.go b/commands/user/user.go new file mode 100644 index 00000000..191fb828 --- /dev/null +++ b/commands/user/user.go @@ -0,0 +1,89 @@ +package usercmd + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" + + json2 "github.com/MichaelMure/git-bug/commands/cmdjson" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/util/colors" +) + +type userOptions struct { + format string +} + +func NewUserCommand() *cobra.Command { + env := execenv.NewEnv() + options := userOptions{} + + cmd := &cobra.Command{ + Use: "user", + Short: "List identities", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runUser(env, options) + }), + } + + cmd.AddCommand(newUserNewCommand()) + cmd.AddCommand(newUserShowCommand()) + cmd.AddCommand(newUserAdoptCommand()) + + flags := cmd.Flags() + flags.SortFlags = false + + flags.StringVarP(&options.format, "format", "f", "default", + "Select the output formatting style. Valid values are [default,json]") + cmd.RegisterFlagCompletionFunc("format", completion.From([]string{"default", "json"})) + + return cmd +} + +func runUser(env *execenv.Env, opts userOptions) error { + ids := env.Backend.AllIdentityIds() + var users []*cache.IdentityExcerpt + for _, id := range ids { + user, err := env.Backend.ResolveIdentityExcerpt(id) + if err != nil { + return err + } + users = append(users, user) + } + + switch opts.format { + case "json": + return userJsonFormatter(env, users) + case "default": + return userDefaultFormatter(env, users) + default: + return fmt.Errorf("unknown format %s", opts.format) + } +} + +func userDefaultFormatter(env *execenv.Env, users []*cache.IdentityExcerpt) error { + for _, user := range users { + env.Out.Printf("%s %s\n", + colors.Cyan(user.Id.Human()), + user.DisplayName(), + ) + } + + return nil +} + +func userJsonFormatter(env *execenv.Env, users []*cache.IdentityExcerpt) error { + jsonUsers := make([]json2.Identity, len(users)) + for i, user := range users { + jsonUsers[i] = json2.NewIdentityFromExcerpt(user) + } + + jsonObject, _ := json.MarshalIndent(jsonUsers, "", " ") + env.Out.Printf("%s\n", jsonObject) + return nil +} diff --git a/commands/user/user_adopt.go b/commands/user/user_adopt.go new file mode 100644 index 00000000..f5944053 --- /dev/null +++ b/commands/user/user_adopt.go @@ -0,0 +1,43 @@ +package usercmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +func newUserAdoptCommand() *cobra.Command { + env := execenv.NewEnv() + + cmd := &cobra.Command{ + Use: "adopt USER_ID", + Short: "Adopt an existing identity as your own", + Args: cobra.ExactArgs(1), + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runUserAdopt(env, args) + }), + ValidArgsFunction: completion.User(env), + } + + return cmd +} + +func runUserAdopt(env *execenv.Env, args []string) error { + prefix := args[0] + + i, err := env.Backend.ResolveIdentityPrefix(prefix) + if err != nil { + return err + } + + err = env.Backend.SetUserIdentity(i) + if err != nil { + return err + } + + env.Out.Printf("Your identity is now: %s\n", i.DisplayName()) + + return nil +} diff --git a/commands/user/user_new.go b/commands/user/user_new.go new file mode 100644 index 00000000..d7224512 --- /dev/null +++ b/commands/user/user_new.go @@ -0,0 +1,98 @@ +package usercmd + +import ( + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/execenv" + "github.com/MichaelMure/git-bug/commands/input" +) + +type userNewOptions struct { + name string + email string + avatarURL string + nonInteractive bool +} + +func newUserNewCommand() *cobra.Command { + env := execenv.NewEnv() + + options := userNewOptions{} + cmd := &cobra.Command{ + Use: "new", + Short: "Create a new identity", + PreRunE: execenv.LoadBackend(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runUserNew(env, options) + }), + } + + flags := cmd.Flags() + flags.StringVarP(&options.name, "name", "n", "", "Name to identify the user") + flags.StringVarP(&options.email, "email", "e", "", "Email of the user") + flags.StringVarP(&options.avatarURL, "avatar", "a", "", "Avatar URL") + flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") + + return cmd +} + +func runUserNew(env *execenv.Env, opts userNewOptions) error { + + if !opts.nonInteractive && opts.name == "" { + preName, err := env.Backend.GetUserName() + if err != nil { + return err + } + opts.name, err = input.PromptDefault("Name", "name", preName, input.Required) + if err != nil { + return err + } + } + + if !opts.nonInteractive && opts.email == "" { + preEmail, err := env.Backend.GetUserEmail() + if err != nil { + return err + } + + opts.email, err = input.PromptDefault("Email", "email", preEmail, input.Required) + if err != nil { + return err + } + } + + if !opts.nonInteractive && opts.avatarURL == "" { + var err error + opts.avatarURL, err = input.Prompt("Avatar URL", "avatar") + if err != nil { + return err + } + } + + id, err := env.Backend.NewIdentityRaw(opts.name, opts.email, "", opts.avatarURL, nil, nil) + if err != nil { + return err + } + + err = id.CommitAsNeeded() + if err != nil { + return err + } + + set, err := env.Backend.IsUserIdentitySet() + if err != nil { + return err + } + + if !set { + err = env.Backend.SetUserIdentity(id) + if err != nil { + return err + } + } + + env.Err.Println() + env.Out.Println(id.Id()) + + return nil +} diff --git a/commands/user/user_new_test.go b/commands/user/user_new_test.go new file mode 100644 index 00000000..619e5de6 --- /dev/null +++ b/commands/user/user_new_test.go @@ -0,0 +1,14 @@ +package usercmd + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/commands/bug/testenv" +) + +func TestUserNewCommand(t *testing.T) { + _, userID := testenv.NewTestEnvAndUser(t) + require.Regexp(t, "[0-9a-f]{64}", userID) +} diff --git a/commands/user/user_show.go b/commands/user/user_show.go new file mode 100644 index 00000000..36c09e8e --- /dev/null +++ b/commands/user/user_show.go @@ -0,0 +1,108 @@ +package usercmd + +import ( + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/completion" + "github.com/MichaelMure/git-bug/commands/execenv" +) + +type userShowOptions struct { + fields string +} + +func newUserShowCommand() *cobra.Command { + env := execenv.NewEnv() + options := userShowOptions{} + + cmd := &cobra.Command{ + Use: "user show [USER_ID]", + Short: "Display a user identity", + PreRunE: execenv.LoadBackendEnsureUser(env), + RunE: execenv.CloseBackend(env, func(cmd *cobra.Command, args []string) error { + return runUserShow(env, options, args) + }), + ValidArgsFunction: completion.User(env), + } + + flags := cmd.Flags() + flags.SortFlags = false + + fields := []string{"email", "humanId", "id", "lastModification", "lastModificationLamports", "login", "metadata", "name"} + flags.StringVarP(&options.fields, "field", "f", "", + "Select field to display. Valid values are ["+strings.Join(fields, ",")+"]") + cmd.RegisterFlagCompletionFunc("field", completion.From(fields)) + + return cmd +} + +func runUserShow(env *execenv.Env, opts userShowOptions, args []string) error { + if len(args) > 1 { + return errors.New("only one identity can be displayed at a time") + } + + var id *cache.IdentityCache + var err error + if len(args) == 1 { + id, err = env.Backend.ResolveIdentityPrefix(args[0]) + } else { + id, err = env.Backend.GetUserIdentity() + } + + if err != nil { + return err + } + + if opts.fields != "" { + switch opts.fields { + case "email": + env.Out.Printf("%s\n", id.Email()) + case "login": + env.Out.Printf("%s\n", id.Login()) + case "humanId": + env.Out.Printf("%s\n", id.Id().Human()) + case "id": + env.Out.Printf("%s\n", id.Id()) + case "lastModification": + env.Out.Printf("%s\n", id.LastModification(). + Time().Format("Mon Jan 2 15:04:05 2006 +0200")) + case "lastModificationLamport": + for name, t := range id.LastModificationLamports() { + env.Out.Printf("%s\n%d\n", name, t) + } + case "metadata": + for key, value := range id.ImmutableMetadata() { + env.Out.Printf("%s\n%s\n", key, value) + } + case "name": + env.Out.Printf("%s\n", id.Name()) + + default: + return fmt.Errorf("\nUnsupported field: %s\n", opts.fields) + } + + return nil + } + + env.Out.Printf("Id: %s\n", id.Id()) + env.Out.Printf("Name: %s\n", id.Name()) + env.Out.Printf("Email: %s\n", id.Email()) + env.Out.Printf("Login: %s\n", id.Login()) + env.Out.Printf("Last modification: %s\n", id.LastModification().Time().Format("Mon Jan 2 15:04:05 2006 +0200")) + env.Out.Printf("Last moditication (lamport):\n") + for name, t := range id.LastModificationLamports() { + env.Out.Printf("\t%s: %d", name, t) + } + env.Out.Println("Metadata:") + for key, value := range id.ImmutableMetadata() { + env.Out.Printf(" %s --> %s\n", key, value) + } + // env.Out.Printf("Protected: %v\n", id.IsProtected()) + + return nil +} diff --git a/commands/user_adopt.go b/commands/user_adopt.go deleted file mode 100644 index afef94ea..00000000 --- a/commands/user_adopt.go +++ /dev/null @@ -1,40 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" -) - -func newUserAdoptCommand() *cobra.Command { - env := newEnv() - - cmd := &cobra.Command{ - Use: "adopt USER-ID", - Short: "Adopt an existing identity as your own.", - Args: cobra.ExactArgs(1), - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runUserAdopt(env, args) - }), - ValidArgsFunction: completeUser(env), - } - - return cmd -} - -func runUserAdopt(env *Env, args []string) error { - prefix := args[0] - - i, err := env.backend.ResolveIdentityPrefix(prefix) - if err != nil { - return err - } - - err = env.backend.SetUserIdentity(i) - if err != nil { - return err - } - - env.out.Printf("Your identity is now: %s\n", i.DisplayName()) - - return nil -} diff --git a/commands/user_create.go b/commands/user_create.go deleted file mode 100644 index 6941cff5..00000000 --- a/commands/user_create.go +++ /dev/null @@ -1,97 +0,0 @@ -package commands - -import ( - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/commands/input" -) - -type createUserOptions struct { - name string - email string - avatarURL string - nonInteractive bool -} - -func newUserCreateCommand() *cobra.Command { - env := newEnv() - - options := createUserOptions{} - cmd := &cobra.Command{ - Use: "create", - Short: "Create a new identity.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runUserCreate(env, options) - }), - } - - flags := cmd.Flags() - flags.StringVarP(&options.name, "name", "n", "", "Name to identify the user") - flags.StringVarP(&options.email, "email", "e", "", "Email of the user") - flags.StringVarP(&options.avatarURL, "avatar", "a", "", "Avatar URL") - flags.BoolVar(&options.nonInteractive, "non-interactive", false, "Do not ask for user input") - - return cmd -} - -func runUserCreate(env *Env, opts createUserOptions) error { - - if !opts.nonInteractive && opts.name == "" { - preName, err := env.backend.GetUserName() - if err != nil { - return err - } - opts.name, err = input.PromptDefault("Name", "name", preName, input.Required) - if err != nil { - return err - } - } - - if !opts.nonInteractive && opts.email == "" { - preEmail, err := env.backend.GetUserEmail() - if err != nil { - return err - } - - opts.email, err = input.PromptDefault("Email", "email", preEmail, input.Required) - if err != nil { - return err - } - } - - if !opts.nonInteractive && opts.avatarURL == "" { - var err error - opts.avatarURL, err = input.Prompt("Avatar URL", "avatar") - if err != nil { - return err - } - } - - id, err := env.backend.NewIdentityRaw(opts.name, opts.email, "", opts.avatarURL, nil, nil) - if err != nil { - return err - } - - err = id.CommitAsNeeded() - if err != nil { - return err - } - - set, err := env.backend.IsUserIdentitySet() - if err != nil { - return err - } - - if !set { - err = env.backend.SetUserIdentity(id) - if err != nil { - return err - } - } - - env.err.Println() - env.out.Println(id.Id()) - - return nil -} diff --git a/commands/user_create_test.go b/commands/user_create_test.go deleted file mode 100644 index 08958344..00000000 --- a/commands/user_create_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package commands - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -const ( - testUserName = "John Doe" - testUserEmail = "jdoe@example.com" -) - -func newTestEnvAndUser(t *testing.T) (*testEnv, string) { - t.Helper() - - testEnv := newTestEnv(t) - - opts := createUserOptions{ - name: testUserName, - email: testUserEmail, - avatarURL: "", - nonInteractive: true, - } - - require.NoError(t, runUserCreate(testEnv.env, opts)) - - userID := strings.TrimSpace(testEnv.out.String()) - testEnv.out.Reset() - - return testEnv, userID -} - -func TestUserCreateCommand(t *testing.T) { - _, userID := newTestEnvAndUser(t) - require.Regexp(t, "[0-9a-f]{64}", userID) -} diff --git a/commands/user_ls.go b/commands/user_ls.go deleted file mode 100644 index 341f0dc1..00000000 --- a/commands/user_ls.go +++ /dev/null @@ -1,81 +0,0 @@ -package commands - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - - "github.com/MichaelMure/git-bug/cache" - "github.com/MichaelMure/git-bug/util/colors" -) - -type userLsOptions struct { - format string -} - -func newUserLsCommand() *cobra.Command { - env := newEnv() - options := userLsOptions{} - - cmd := &cobra.Command{ - Use: "ls", - Short: "List identities.", - PreRunE: loadBackend(env), - RunE: closeBackend(env, func(cmd *cobra.Command, args []string) error { - return runUserLs(env, options) - }), - } - - flags := cmd.Flags() - flags.SortFlags = false - - flags.StringVarP(&options.format, "format", "f", "default", - "Select the output formatting style. Valid values are [default,json]") - cmd.RegisterFlagCompletionFunc("format", completeFrom([]string{"default", "json"})) - - return cmd -} - -func runUserLs(env *Env, opts userLsOptions) error { - ids := env.backend.AllIdentityIds() - var users []*cache.IdentityExcerpt - for _, id := range ids { - user, err := env.backend.ResolveIdentityExcerpt(id) - if err != nil { - return err - } - users = append(users, user) - } - - switch opts.format { - case "json": - return userLsJsonFormatter(env, users) - case "default": - return userLsDefaultFormatter(env, users) - default: - return fmt.Errorf("unknown format %s", opts.format) - } -} - -func userLsDefaultFormatter(env *Env, users []*cache.IdentityExcerpt) error { - for _, user := range users { - env.out.Printf("%s %s\n", - colors.Cyan(user.Id.Human()), - user.DisplayName(), - ) - } - - return nil -} - -func userLsJsonFormatter(env *Env, users []*cache.IdentityExcerpt) error { - jsonUsers := make([]JSONIdentity, len(users)) - for i, user := range users { - jsonUsers[i] = NewJSONIdentityFromExcerpt(user) - } - - jsonObject, _ := json.MarshalIndent(jsonUsers, "", " ") - env.out.Printf("%s\n", jsonObject) - return nil -} diff --git a/commands/version.go b/commands/version.go index 71baba40..0e54bb92 100644 --- a/commands/version.go +++ b/commands/version.go @@ -4,6 +4,8 @@ import ( "runtime" "github.com/spf13/cobra" + + "github.com/MichaelMure/git-bug/commands/execenv" ) type versionOptions struct { @@ -13,12 +15,12 @@ type versionOptions struct { } func newVersionCommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() options := versionOptions{} cmd := &cobra.Command{ Use: "version", - Short: "Show git-bug version information.", + Short: "Show git-bug version information", Run: func(cmd *cobra.Command, args []string) { runVersion(env, options, cmd.Root()) }, @@ -40,23 +42,23 @@ func newVersionCommand() *cobra.Command { return cmd } -func runVersion(env *Env, opts versionOptions, root *cobra.Command) { +func runVersion(env *execenv.Env, opts versionOptions, root *cobra.Command) { if opts.all { - env.out.Printf("%s version: %s\n", rootCommandName, root.Version) - env.out.Printf("System version: %s/%s\n", runtime.GOARCH, runtime.GOOS) - env.out.Printf("Golang version: %s\n", runtime.Version()) + env.Out.Printf("%s version: %s\n", execenv.RootCommandName, root.Version) + env.Out.Printf("System version: %s/%s\n", runtime.GOARCH, runtime.GOOS) + env.Out.Printf("Golang version: %s\n", runtime.Version()) return } if opts.number { - env.out.Println(root.Version) + env.Out.Println(root.Version) return } if opts.commit { - env.out.Println(GitCommit) + env.Out.Println(GitCommit) return } - env.out.Printf("%s version: %s\n", rootCommandName, root.Version) + env.Out.Printf("%s version: %s\n", execenv.RootCommandName, root.Version) } diff --git a/commands/webui.go b/commands/webui.go index 758a153b..5fe66aa7 100644 --- a/commands/webui.go +++ b/commands/webui.go @@ -23,6 +23,7 @@ import ( "github.com/MichaelMure/git-bug/api/graphql" httpapi "github.com/MichaelMure/git-bug/api/http" "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/commands/execenv" "github.com/MichaelMure/git-bug/entities/identity" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/webui" @@ -41,18 +42,18 @@ type webUIOptions struct { } func newWebUICommand() *cobra.Command { - env := newEnv() + env := execenv.NewEnv() options := webUIOptions{} cmd := &cobra.Command{ Use: "webui", - Short: "Launch the web UI.", + Short: "Launch the web UI", Long: `Launch the web UI. Available git config: git-bug.webui.open [bool]: control the automatic opening of the web UI in the default browser `, - PreRunE: loadRepo(env), + PreRunE: execenv.LoadRepo(env), RunE: func(cmd *cobra.Command, args []string) error { return runWebUI(env, options) }, @@ -72,7 +73,7 @@ Available git config: return cmd } -func runWebUI(env *Env, opts webUIOptions) error { +func runWebUI(env *execenv.Env, opts webUIOptions) error { if opts.port == 0 { var err error opts.port, err = freeport.GetFreePort() @@ -96,7 +97,7 @@ func runWebUI(env *Env, opts webUIOptions) error { // fixed identity: the default user of the repo // TODO: support dynamic authentication with OAuth if !opts.readOnly { - author, err := identity.GetUserIdentity(env.repo) + author, err := identity.GetUserIdentity(env.Repo) if err != nil { return err } @@ -104,14 +105,14 @@ func runWebUI(env *Env, opts webUIOptions) error { } mrc := cache.NewMultiRepoCache() - _, err := mrc.RegisterDefaultRepository(env.repo) + _, err := mrc.RegisterDefaultRepository(env.Repo) if err != nil { return err } var errOut io.Writer if opts.logErrors { - errOut = env.err + errOut = env.Err } graphqlHandler := graphql.NewHandler(mrc, errOut) @@ -136,7 +137,7 @@ func runWebUI(env *Env, opts webUIOptions) error { go func() { <-quit - env.out.Println("WebUI is shutting down...") + env.Out.Println("WebUI is shutting down...") ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -149,18 +150,18 @@ func runWebUI(env *Env, opts webUIOptions) error { // Teardown err := graphqlHandler.Close() if err != nil { - env.out.Println(err) + env.Out.Println(err) } close(done) }() - env.out.Printf("Web UI: %s\n", webUiAddr) - env.out.Printf("Graphql API: http://%s/graphql\n", addr) - env.out.Printf("Graphql Playground: http://%s/playground\n", addr) - env.out.Println("Press Ctrl+c to quit") + env.Out.Printf("Web UI: %s\n", webUiAddr) + env.Out.Printf("Graphql API: http://%s/graphql\n", addr) + env.Out.Printf("Graphql Playground: http://%s/playground\n", addr) + env.Out.Println("Press Ctrl+c to quit") - configOpen, err := env.repo.AnyConfig().ReadBool(webUIOpenConfigKey) + configOpen, err := env.Repo.AnyConfig().ReadBool(webUIOpenConfigKey) if err == repository.ErrNoConfigEntry { // default to true configOpen = true @@ -173,7 +174,7 @@ func runWebUI(env *Env, opts webUIOptions) error { if shouldOpen { err = open.Run(toOpen) if err != nil { - env.out.Println(err) + env.Out.Println(err) } } @@ -184,6 +185,6 @@ func runWebUI(env *Env, opts webUIOptions) error { <-done - env.out.Println("WebUI stopped") + env.Out.Println("WebUI stopped") return nil } -- cgit