diff --git a/README.md b/README.md index a4f94e3..c9f31f4 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,17 @@ `jt` is a CLI tool for viewing and manipulating JIRA issues. -An example usage to transition an issue to a new status: +One common example usage to transition an issue to a new status: ``` jt "In Progress" TEAM-1234 ``` -If you are in a git repository on a topic branch who's name matches `team-1234[-whatever]`, you can omit +If you are in a git repository where the topic branch's name matches `[whatever-]team-1234[-whatever]`, you can omit the issue argument as it is implied. -### Usage: +Yeah, we even let you use underscores. + +### Common Usage: jt [new state] [issue number] **Note:** @@ -30,11 +32,11 @@ JIRA board's workflow. ### Other Available Commands: | command | what it does | |---|---| -| completion | generate the autocompletion script for the specified shell | -| help | Help about any command | | onit | Self-assign and transition an issue to In Progress status | | take | Assign an issue to you | | wti | What The Issue? - View an issue in Github Markdown | +| completion | generate the autocompletion script for the specified shell | +| help | Help about any command | Shared Flags: | flag | what it does | diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..b07d641 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,252 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/StevenACoffman/jt/pkg/atlassian" + "github.com/StevenACoffman/jt/pkg/colors" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" +) + +// configCmd represents the config command +var configCmd = &cobra.Command{ + Use: "config", + Short: "Save your JIRA config for use in other commands", + Long: `This will ask for your JIRA token, tenant URL and email. +It will backup any existing config file and make a new one.`, + Run: func(cmd *cobra.Command, args []string) { + configure() + os.Exit(exitSuccess) + }, +} + +func configure() { + if atlassian.CheckConfigFileExists(cfgFile) { + + backupErr := BackupConfigFile(cfgFile) + if backupErr != nil { + fmt.Println("Unable to backup config file!") + os.Exit(exitFail) + } + } + model := initialModel() + + if err := tea.NewProgram(&model).Start(); err != nil { + fmt.Printf("could not start program: %s\n", err) + os.Exit(1) + } + + err := atlassian.SaveConfig(cfgFile, jiraConfig) + if err != nil { + fmt.Println(err) + os.Exit(exitFail) + } + jiraClient = atlassian.GetJIRAClient(jiraConfig) + fmt.Println("Successfully wrote config to ", cfgFile) +} + +func init() { + rootCmd.AddCommand(configCmd) + + // Here you will define your flags and configuration settings. + + // Cobra supports Persistent Flags which will work for this command + // and all subcommands, e.g.: + // configCmd.PersistentFlags().String("foo", "", "A help for foo") + + // Cobra supports local flags which will only run when this command + // is called directly, e.g.: + // configCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") +} + +var ( + focusedStyle = lipgloss.NewStyle().Foreground( + lipgloss.AdaptiveColor{ + Light: colors.ANSIGreen.String(), + Dark: colors.ANSIBrightGreen.String(), + }) + blurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) //#585858 + cursorStyle = focusedStyle.Copy() + noStyle = lipgloss.NewStyle() + helpStyle = blurredStyle.Copy() + cursorModeHelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) //#808080 + + focusedButton = focusedStyle.Copy().Render("[ Submit ]") + blurredButton = fmt.Sprintf("[ %s ]", blurredStyle.Render("Submit")) +) + +type model struct { + focusIndex int + inputs []textinput.Model + cursorMode textinput.CursorMode + choice chan *atlassian.Config +} + +func initialModel() model { + m := model{ + inputs: make([]textinput.Model, 3), + } + + var t textinput.Model + for i := range m.inputs { + t = textinput.NewModel() + t.CursorStyle = cursorStyle + t.CharLimit = 128 + + switch i { + case 0: + t.Placeholder = "Paste token here" + t.Focus() + t.PromptStyle = focusedStyle + t.TextStyle = focusedStyle + t.EchoMode = textinput.EchoPassword + t.EchoCharacter = '•' + case 1: + t.Placeholder = "Host URL like https://tenant.atlassian.net" + case 2: + t.Placeholder = "Email" + } + + m.inputs[i] = t + } + + return m +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + return m, tea.Quit + + // Change cursor mode + case "ctrl+r": + m.cursorMode++ + if m.cursorMode > textinput.CursorHide { + m.cursorMode = textinput.CursorBlink + } + cmds := make([]tea.Cmd, len(m.inputs)) + for i := range m.inputs { + cmds[i] = m.inputs[i].SetCursorMode(m.cursorMode) + } + return m, tea.Batch(cmds...) + + // Set focus to next input + case "tab", "shift+tab", "enter", "up", "down": + s := msg.String() + + // Did the user press enter while the submit button was focused? + // If so, save choices and exit. + if s == "enter" && m.focusIndex == len(m.inputs) { + if jiraConfig == nil { + jiraConfig = &atlassian.Config{} + } + for i, input := range m.inputs { + switch i { + case 0: + jiraConfig.Token = input.Value() + case 1: + jiraConfig.Host = input.Value() + case 2: + jiraConfig.User = input.Value() + } + } + return m, tea.Quit + } + + // Cycle indexes + if s == "up" || s == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex > len(m.inputs) { + m.focusIndex = 0 + } else if m.focusIndex < 0 { + m.focusIndex = len(m.inputs) + } + + cmds := make([]tea.Cmd, len(m.inputs)) + for i := 0; i <= len(m.inputs)-1; i++ { + if i == m.focusIndex { + // Set focused state + cmds[i] = m.inputs[i].Focus() + m.inputs[i].PromptStyle = focusedStyle + m.inputs[i].TextStyle = focusedStyle + continue + } + // Remove focused state + m.inputs[i].Blur() + m.inputs[i].PromptStyle = noStyle + m.inputs[i].TextStyle = noStyle + } + + return m, tea.Batch(cmds...) + } + } + + // Handle character input and blinking + cmd := m.updateInputs(msg) + + return m, cmd +} + +func (m *model) updateInputs(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, len(m.inputs)) + + // Only text inputs with Focus() set will respond, so it's safe to simply + // update all of them here without any further logic. + for i := range m.inputs { + m.inputs[i], cmds[i] = m.inputs[i].Update(msg) + } + + return tea.Batch(cmds...) +} + +func (m model) View() string { + var b strings.Builder + b.WriteString("It looks like we need a Jira API Token.\n\n") + styledLink := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "4", Dark: "12"}). // Dark Blue or LightBlue + Underline(true). + Render("https://id.atlassian.com/manage/api-tokens") + b.WriteString(fmt.Sprintf( + "First, go to %s to create a personal api token.\n", + styledLink)) + + for i := range m.inputs { + b.WriteString(m.inputs[i].View()) + if i < len(m.inputs)-1 { + b.WriteRune('\n') + } + } + + button := &blurredButton + if m.focusIndex == len(m.inputs) { + button = &focusedButton + } + fmt.Fprintf(&b, "\n\n%s\n\n", *button) + + b.WriteString(helpStyle.Render("cursor mode is ")) + b.WriteString(cursorModeHelpStyle.Render(m.cursorMode.String())) + b.WriteString(helpStyle.Render(" (ctrl+r to change style)")) + + return b.String() +} + +func BackupConfigFile(filename string) error { + return os.Rename(filename, filename+".bak") +} diff --git a/cmd/onit.go b/cmd/onit.go index a26fdc0..42c01fc 100644 --- a/cmd/onit.go +++ b/cmd/onit.go @@ -2,9 +2,10 @@ package cmd import ( "fmt" - "github.com/StevenACoffman/jt/pkg/atlassian" "os" + "github.com/StevenACoffman/jt/pkg/atlassian" + "github.com/spf13/cobra" ) @@ -12,14 +13,18 @@ import ( var onitCmd = &cobra.Command{ Use: "onit", Short: "Self-assign and transition an issue to In Progress status", - Long: `Assign the issue to yourself and transition an issue to In Progress status`, + Long: `Assign the issue to yourself and transition an issue to In Progress status`, + Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { - + if jiraConfig == nil { + configure() + } + var issueKey string if len(args) == 0 { - fmt.Println("You failed to pass a jira issue argument") - os.Exit(exitFail) + issueKey = getIssueFromGitBranch() + } else { + issueKey = args[0] } - issueKey := args[0] issue, _, issueErr := jiraClient.Issue.Get(issueKey, nil) if issueErr != nil { fmt.Printf("Unable to get Issue %s: %+v", issueKey, issueErr) diff --git a/cmd/root.go b/cmd/root.go index 8ea49c6..e888bb8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,123 +1,143 @@ package cmd import ( - "fmt" - "github.com/StevenACoffman/jt/pkg/atlassian" - "github.com/spf13/cobra" - "os" - - "github.com/andygrunwald/go-jira" - homedir "github.com/mitchellh/go-homedir" - "github.com/spf13/viper" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/StevenACoffman/jt/pkg/atlassian" + "github.com/StevenACoffman/jt/pkg/git" + + "github.com/andygrunwald/go-jira" + homedir "github.com/mitchellh/go-homedir" + "github.com/spf13/viper" ) const ( - // exitFail is the exit code if the program fails. - exitFail = 1 - // exitSuccess is the exit code if the program succeeds - exitSuccess = 0 + // exitFail is the exit code if the program fails. + exitFail = 1 + // exitSuccess is the exit code if the program succeeds + exitSuccess = 0 ) -var cfgFile string -var jiraClient *jira.Client - +var ( + cfgFile string + jiraClient *jira.Client + jiraConfig *atlassian.Config +) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "jt", - Short: "jt - JIRA Issue Tool", - Long: `jt is a CLI tool for viewing and manipulating JIRA issues.`, - Args: cobra.RangeArgs(1, 2), - // Uncomment the following line if your bare application - // has an action associated with it: - Run: func(cmd *cobra.Command, args []string) { - if len(args) == 0 { - fmt.Println("You need to pass a desired jira status argument (and maybe a jira issue like TEAM-1234)") - os.Exit(exitFail) - } - var issueKey string - statusName := args[0] - if len(args) > 1 { - issueKey = args[1] - } else { - - } - issue, _, issueErr := jiraClient.Issue.Get(issueKey, nil) - if issueErr != nil { - fmt.Printf("Unable to get Issue %s: %+v", issueKey, issueErr) - os.Exit(exitFail) - } - - err := atlassian.MoveIssueToStatusByName(jiraClient, issue, issueKey, statusName) - if err != nil { - fmt.Println(err) - os.Exit(exitFail) - } - os.Exit(exitSuccess) - }, + Use: "jt", + Short: "jt - JIRA Issue Tool", + Long: `jt is a CLI tool for viewing and manipulating JIRA issues.`, + Args: cobra.RangeArgs(1, 2), + // Uncomment the following line if your bare application + // has an action associated with it: + Run: func(cmd *cobra.Command, args []string) { + if jiraConfig == nil { + configure() + } + if len(args) == 0 { + fmt.Println( + "You need to pass a desired jira status argument (and maybe a jira issue like TEAM-1234)", + ) + os.Exit(exitFail) + } + var issueKey string + statusName := args[0] + if len(args) > 1 { + issueKey = args[1] + } else { + issueKey = getIssueFromGitBranch() + } + if issueKey != "" { + fmt.Printf("unable to guess issue ID from branch") + os.Exit(exitFail) + } + + issue, _, issueErr := jiraClient.Issue.Get(issueKey, nil) + if issueErr != nil { + fmt.Printf("Unable to get Issue %s: %+v", issueKey, issueErr) + os.Exit(exitFail) + } + + err := atlassian.MoveIssueToStatusByName(jiraClient, issue, issueKey, statusName) + if err != nil { + fmt.Println(err) + os.Exit(exitFail) + } + os.Exit(exitSuccess) + }, } // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. func Execute() { - if err := rootCmd.Execute(); err != nil { - fmt.Println(err) - os.Exit(1) - } + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } } func init() { - cobra.OnInitialize(initConfig) + cobra.OnInitialize(initConfig) - // Here you will define your flags and configuration settings. - // Cobra supports persistent flags, which, if defined here, - // will be global for your application. + // Here you will define your flags and configuration settings. + // Cobra supports persistent flags, which, if defined here, + // will be global for your application. - rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/jira)") + rootCmd.PersistentFlags(). + StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/jira)") } - // initConfig reads in config file and ENV variables if set. func initConfig() { - // default delimiter is "." and emails contain these - v := viper.NewWithOptions(viper.KeyDelimiter("::")) - v.SetConfigType("json") - - if cfgFile != "" { - // Use config file from the flag. - v.SetConfigFile(cfgFile) - } else { - // Find home directory. - home, err := homedir.Dir() - if err != nil { - fmt.Println(err) - os.Exit(1) - } - - // Search config in home directory with name ".jt" (without extension). - v.AddConfigPath(home+"/.config") - v.SetConfigName("jira") - } - - // If a config file is found, read it in. - if err := v.ReadInConfig(); err != nil { - fmt.Println("Unable to read config using config file:", v.ConfigFileUsed()) - return - } - - jiraConfig := atlassian.Config{ - Token: getEnv("ATLASSIAN_API_TOKEN", v.GetString("token")), - User: getEnv("ATLASSIAN_API_USER", v.GetString("user")), - Host: getEnv("ATLASSIAN_HOST", v.GetString("host")), - } - jiraClient = atlassian.GetJIRAClient(&jiraConfig) - + // default delimiter is "." and emails contain these + v := viper.NewWithOptions(viper.KeyDelimiter("::")) + v.SetConfigType("json") + + if cfgFile != "" { + // Use config file from the flag. + v.SetConfigFile(cfgFile) + } else { + // Find home directory. + home, err := homedir.Dir() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // Search config in home directory with name ".config/jira" (without extension). + v.AddConfigPath(home + "/.config") + v.SetConfigName("jira") + cfgFile = home + "/.config/jira" + } + + // If a config file is found, read it in. + if err := v.ReadInConfig(); err != nil { + fmt.Println("Unable to read config using config file:", v.ConfigFileUsed()) + return + } + + jiraConfig = &atlassian.Config{ + Token: getEnv("ATLASSIAN_API_TOKEN", v.GetString("token")), + User: getEnv("ATLASSIAN_API_USER", v.GetString("user")), + Host: getEnv("ATLASSIAN_HOST", v.GetString("host")), + } + jiraClient = atlassian.GetJIRAClient(jiraConfig) } func getEnv(key, fallback string) string { - if value, ok := os.LookupEnv(key); ok { - return value - } - return fallback + if value, ok := os.LookupEnv(key); ok { + return value + } + return fallback } +func getIssueFromGitBranch() string { + branch := git.CurrentBranch() + + return atlassian.ParseJiraIssueFromBranch(branch) +} diff --git a/cmd/take.go b/cmd/take.go index 26d848f..6e088f4 100644 --- a/cmd/take.go +++ b/cmd/take.go @@ -2,9 +2,10 @@ package cmd import ( "fmt" - "github.com/StevenACoffman/jt/pkg/atlassian" "os" + "github.com/StevenACoffman/jt/pkg/atlassian" + "github.com/spf13/cobra" ) @@ -12,13 +13,18 @@ import ( var takeCmd = &cobra.Command{ Use: "take", Short: "Assign an issue to you", - Long: `Assign an issue to you`, + Long: `Assign an issue to you`, + Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { + if jiraConfig == nil { + configure() + } + var issueKey string if len(args) == 0 { - fmt.Println("You failed to pass a jira issue argument") - return + issueKey = getIssueFromGitBranch() + } else { + issueKey = args[0] } - issueKey := args[0] issue, _, issueErr := jiraClient.Issue.Get(issueKey, nil) if issueErr != nil { fmt.Printf("Unable to get Issue %s: %+v", issueKey, issueErr) diff --git a/cmd/wti.go b/cmd/wti.go index dd2eeb8..b287a5a 100644 --- a/cmd/wti.go +++ b/cmd/wti.go @@ -2,23 +2,30 @@ package cmd import ( "fmt" + "github.com/StevenACoffman/jt/pkg/atlassian" "github.com/spf13/cobra" ) + var omitTitle, omitDescription bool + // wtiCmd represents the wti command var wtiCmd = &cobra.Command{ Use: "wti", Short: "What The Issue? - View an issue", - Long: `What The Issue? Will View an issue.`, - Args: cobra.MaximumNArgs(1), + Long: `What The Issue? Will View an issue.`, + Args: cobra.RangeArgs(0, 1), Run: func(cmd *cobra.Command, args []string) { + if jiraConfig == nil { + configure() + } + var issueKey string if len(args) == 0 { - fmt.Println("You failed to pass a jira issue argument") - return + issueKey = getIssueFromGitBranch() + } else { + issueKey = args[0] } - issueKey := args[0] jiraIssue, issueErr := atlassian.GetIssue(jiraClient, issueKey) if issueErr != nil { @@ -35,7 +42,6 @@ var wtiCmd = &cobra.Command{ jiraClient, jiraIssue.Fields.Description)) } } - }, } @@ -53,5 +59,5 @@ func init() { flags := wtiCmd.Flags() //.BoolP("toggle", "t", false, "Help message for toggle") flags.BoolVarP(&omitTitle, "no-title", "t", false, "Do Not Print Title") - flags.BoolVarP(&omitDescription, "no-description","d", false, "Do Not Print Description") + flags.BoolVarP(&omitDescription, "no-description", "d", false, "Do Not Print Description") } diff --git a/go.mod b/go.mod index d4eb1bc..b2184ed 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,12 @@ go 1.15 require ( github.com/andygrunwald/go-jira v1.14.0 + github.com/charmbracelet/bubbles v0.8.0 + github.com/charmbracelet/bubbletea v0.14.1 + github.com/charmbracelet/lipgloss v0.1.2 github.com/magefile/mage v1.11.0 github.com/mitchellh/go-homedir v1.0.0 + github.com/mritd/bubbles v0.0.0-20210825105013-cb7a572fb831 github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.8.1 moul.io/http2curl v1.0.0 diff --git a/go.sum b/go.sum index eb2b949..aaad429 100644 --- a/go.sum +++ b/go.sum @@ -45,9 +45,18 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/atotto/clipboard v0.1.2 h1:YZCtFu5Ie8qX2VmVTBnrqLSiU9XOWwqNRmdT3gIQzbY= +github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbles v0.8.0 h1:+l2op90Ag37Vn+30O1hbg/0wBl+e+sxHhgY1F/rvdHs= +github.com/charmbracelet/bubbles v0.8.0/go.mod h1:5WX1sSSjNCgCrzvRMN/z23HxvWaa+AI16Ch0KPZPeDs= +github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg= +github.com/charmbracelet/bubbletea v0.14.1 h1:pD/bM5LBEH/nDo7nKcgNUgi4uRHQhpWTIHZbG5vuSlc= +github.com/charmbracelet/bubbletea v0.14.1/go.mod h1:b5lOf5mLjMg1tRn1HVla54guZB+jvsyV0yYAQja95zE= +github.com/charmbracelet/lipgloss v0.1.2 h1:D+LUMg34W7n2pkuMrevKVxT7HXqnoRHm7IoomkX3/ZU= +github.com/charmbracelet/lipgloss v0.1.2/go.mod h1:5D8zradw52m7QmxRF6QgwbwJi9je84g8MkWiGN07uKg= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -55,6 +64,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/containerd/console v1.0.1 h1:u7SFAJyRqWcG6ogaMAx3KjSTy1e3hT9QxqX7Jco7dRc= +github.com/containerd/console v1.0.1/go.mod h1:XUsP6YE/mKtz6bxc+I8UiKKTP04qjQL4qcS3XoQ5xkw= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -128,6 +139,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135 h1:zLTLjkaOFEFIOxY5BWLFLwh+cL8vOBW4XJ2aqLE/Tf0= github.com/google/go-querystring v0.0.0-20170111101155-53e6ce116135/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -187,12 +199,23 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.11.0 h1:C/55Ywp9BpgVVclD3lRnSYCwXTYxmSppIgLeDYlNuls= github.com/magefile/mage v1.11.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= github.com/mitchellh/go-homedir v1.0.0 h1:vKb8ShqSby24Yrqr/yDYkuFz8d0WUjys40rvnGC8aR0= @@ -207,6 +230,15 @@ github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/mritd/bubbles v0.0.0-20210825105013-cb7a572fb831 h1:XE7TjK/eZ2a1i1hIQaKu2x5+QvlP+xSnTy/Qp+IjHy0= +github.com/mritd/bubbles v0.0.0-20210825105013-cb7a572fb831/go.mod h1:MaI0jfquQFixw2TyYclzoPfygOv9w5a/X9FpAPbQX8o= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8= +github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= +github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -218,6 +250,9 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -277,6 +312,8 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -387,6 +424,7 @@ golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -400,7 +438,9 @@ golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200916030750-2334cc1a136f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201009025420-dfb3f7c4e634/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -415,6 +455,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007 h1:gG67DSER+11cZvqIMb8S8bt0v golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/mage.go b/mage.go index a6080ff..c1392b2 100644 --- a/mage.go +++ b/mage.go @@ -4,7 +4,8 @@ package main import ( "os" + "github.com/magefile/mage/mage" ) -func main() { os.Exit(mage.Main()) } \ No newline at end of file +func main() { os.Exit(mage.Main()) } diff --git a/magefile.go b/magefile.go index dfbecd8..c8c74ba 100644 --- a/magefile.go +++ b/magefile.go @@ -18,7 +18,7 @@ var ( // Default target to run when none is specified // If not set, running mage will list available targets Default = Install - appName = "jt" + appName = "jt" installPath string currentWorkDir string // allow user to override go executable by running as GOEXE=xxx make ... on unix-like systems @@ -36,7 +36,7 @@ func Build() error { func Install() error { mg.Deps(Build) fmt.Println("Installing...") - return os.Rename(filepath.Join(currentWorkDir,"jt"), installPath) + return os.Rename(filepath.Join(currentWorkDir, "jt"), installPath) } // Clean up after yourself @@ -51,7 +51,9 @@ var releaseTag = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`) // really only useful for maintainers func Release(tag string) (err error) { if !releaseTag.MatchString(tag) { - return errors.New("TAG environment variable must be in semver vx.x.x format, but was " + tag) + return errors.New( + "TAG environment variable must be in semver vx.x.x format, but was " + tag, + ) } if err := sh.RunV("git", "tag", "-a", tag, "-m", tag); err != nil { @@ -69,7 +71,6 @@ func Release(tag string) (err error) { return sh.RunV("goreleaser", "--rm-dist") } - // tag returns the git tag for the current branch or "" if none. func tag() string { s, _ := sh.Output("git", "describe", "--tags") @@ -82,7 +83,6 @@ func hash() string { return hash } - func getEnv(key, fallback string) string { if value, ok := os.LookupEnv(key); ok { return value @@ -120,4 +120,4 @@ func init() { os.Setenv("INSTALLPATH", installPath) os.Setenv("PATH", fmt.Sprintf("%s:%s", goBinDir, os.Getenv("PATH"))) os.Setenv("GOPRIVATE", "github.com/Khan") -} \ No newline at end of file +} diff --git a/main.go b/main.go index 0d7201d..efa9620 100644 --- a/main.go +++ b/main.go @@ -3,5 +3,5 @@ package main import "github.com/StevenACoffman/jt/cmd" func main() { - cmd.Execute() + cmd.Execute() } diff --git a/pkg/atlassian/config.go b/pkg/atlassian/config.go index da5cffc..d269d11 100644 --- a/pkg/atlassian/config.go +++ b/pkg/atlassian/config.go @@ -12,8 +12,8 @@ import ( // Config struct type Config struct { - Host string `json:"host" mapstructure:"host"` - User string `json:"user" mapstructure:"user"` + Host string `json:"host" mapstructure:"host"` + User string `json:"user" mapstructure:"user"` Token string `json:"token" mapstructure:"token"` } @@ -96,3 +96,45 @@ func expandTilde(path string) (string, error) { } return "/" + filepath.Join(paths...), nil } + +// CheckWriteable checks if config file is writeable. This should +// be called before asking for credentials +func CheckWriteable(filename string) error { + err := os.MkdirAll(filepath.Dir(filename), 0o771) + if err != nil { + return err + } + + w, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) + if err != nil { + return err + } + w.Close() + + return nil +} + +func CheckConfigFileExists(filename string) bool { + if _, err := os.Stat(filename); err == nil { + return true + } + return false +} + +func SaveConfig(filename string, c *Config) error { + err := os.MkdirAll(filepath.Dir(filename), 0o771) + if err != nil { + return err + } + + w, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer w.Close() + file, marshalErr := json.Marshal(c) + if marshalErr != nil { + return marshalErr + } + return ioutil.WriteFile(filename, file, 0o644) +} diff --git a/pkg/atlassian/jira.go b/pkg/atlassian/jira.go index c645924..f85d0e1 100644 --- a/pkg/atlassian/jira.go +++ b/pkg/atlassian/jira.go @@ -9,9 +9,9 @@ import ( "strings" "unicode" - "github.com/StevenACoffman/jt/pkg/middleware" - "github.com/andygrunwald/go-jira" + + "github.com/StevenACoffman/jt/pkg/middleware" ) // GetJIRAClient takes a config, and makes a JIRAClient configured @@ -53,17 +53,159 @@ func GetIssue(jiraClient *jira.Client, issue string) (*jira.Issue, error) { return jiraIssue, nil } -// Jiration - convenience for Jira Markup to Github Markdown translation rule -type Jiration struct { +func AssignIssueToSelf(jiraClient *jira.Client, issue *jira.Issue, issueKey string) error { + self, _, selfErr := jiraClient.User.GetSelf() + if selfErr != nil { + return fmt.Errorf("unable to get myself: %+v", selfErr) + } + + if issue.Fields.Assignee == nil || self.AccountID != issue.Fields.Assignee.AccountID { + _, assignErr := jiraClient.Issue.UpdateAssignee(issueKey, self) + if assignErr != nil { + return fmt.Errorf("unable to assign %s to yourself: %+v", issueKey, assignErr) + } + fmt.Printf("Re-Assigned %s from %s\n", issueKey, displayJiraUser(issue.Fields.Assignee)) + } else { + fmt.Println("Already assigned to to you") + } + return nil +} + +// ParseJiraIssueFromBranch - Sanitizes input +// + Trims leading "feature/" (or whatever GIT_BRANCH_PREFIX set to) +// + Trims leading and trailing whitespace +// + Trims anything after ABCD-1234 +// If there is no jira issue match, returns whatever was passed +func ParseJiraIssueFromBranch(issueKey string) string { + if issueKey == "" { + // nothing to do here, I guess + return issueKey + } + branchPrefix := getEnv("GIT_BRANCH_PREFIX", "feature/") + + issueKey = strings.TrimSpace(issueKey) + issueKey = strings.TrimPrefix(issueKey, branchPrefix) + if issueKey == "" { + return issueKey + } + + res := trimJira(issueKey) + + return res +} + +// trimJira will remove everything before and after the last ABCD-1234 or ABCD_1234 +// returns empty string if no jira issue is found +func trimJira(s string) string { + var re *regexp.Regexp + var result string + re = regexp.MustCompile("([a-zA-Z]{1,4}-[1-9][0-9]{0,6})") + matches := re.FindAllStringSubmatch(strings.Replace(s, "_", "-", -1), -1) + for _, match := range matches { + for _, m := range match { + result = m + } + } + return result +} + +func MoveIssueToStatusByName( + jiraClient *jira.Client, + issue *jira.Issue, + issueKey string, + statusName string, +) error { + originalStatus := issue.Fields.Status.Name + if issue.Fields.Status.Name == statusName || + caseInsensitiveContains(issue.Fields.Status.Name, statusName) { + return fmt.Errorf("issue is Already in Status %s\n", issue.Fields.Status.Name) + } + + err := transitionIssueByStatusName(jiraClient, issueKey, statusName) + if err != nil { + return err + } + issue, _, err = jiraClient.Issue.Get(issueKey, nil) + if err != nil { + return err + } + fmt.Printf("Issue %s Status successfully changed from: %s and set to: %+v\n", + issueKey, originalStatus, issue.Fields.Status.Name) + + return nil +} + +func transitionIssueByStatusName( + jiraClient *jira.Client, + issueKey string, + statusName string, +) error { + var transitionID string + possibleTransitions, _, err := jiraClient.Issue.GetTransitions(issueKey) + if err != nil { + return err + } + for _, v := range possibleTransitions { + if strings.EqualFold(v.To.Name, statusName) { + transitionID = v.ID + break + } + } + // no exact match, so remove whitespace so that "ToDo" arg will match "TO DO" status + if transitionID == "" { + for _, v := range possibleTransitions { + if strings.EqualFold(removeWhiteSpace(v.To.Name), statusName) { + transitionID = v.ID + break + } + } + } + // still no match, so look for partial, so "Done" arg will match "Deployed / Done" + if transitionID == "" { + // substring match only if exact match fails + for _, v := range possibleTransitions { + if caseInsensitiveContains(v.To.Name, statusName) { + transitionID = v.ID + break + } + } + } + // still no match. sigh. give up. + if transitionID == "" { + return fmt.Errorf("there does not appear to be a valid transition to %s", statusName) + } + _, err = jiraClient.Issue.DoTransition(issueKey, transitionID) + return err +} + +func removeWhiteSpace(str string) string { + var b strings.Builder + b.Grow(len(str)) + for _, ch := range str { + if !unicode.IsSpace(ch) { + b.WriteRune(ch) + } + } + return b.String() +} + +func caseInsensitiveContains(s, substr string) bool { + s, substr = strings.ToUpper(s), strings.ToUpper(substr) + return strings.Contains(s, substr) +} + +// jiration - convenience for Jira Markup to Github Markdown translation rule +type jiration struct { re *regexp.Regexp repl interface{} } // JiraToMD - This uses some regular expressions to make a reasonable translation -// from Jira Markup to Github Markdown. It is not a complete PEG so it will break down -// especially for more complicated nested formatting (lists inside of lists) +// from Jira Markup to Github Markdown. It is not a complete PEG, so it will break down +// especially for more complicated nested formatting (lists inside of lists). +// pandoc seems to have different problems. Pick your poison. func JiraToMD(str string) string { - jirations := []Jiration{ + jirations := []jiration{ { // UnOrdered Lists re: regexp.MustCompile(`(?m)^[ \t]*(\*+)\s+`), repl: func(groups []string) string { @@ -182,7 +324,7 @@ func JiraToMD(str string) string { case string: str = jiration.re.ReplaceAllString(str, v) case func([]string) string: - str = ReplaceAllStringSubmatchFunc(jiration.re, str, v) + str = replaceAllStringSubmatchFunc(jiration.re, str, v) default: fmt.Printf("I don't know about type %T!\n", v) } @@ -190,13 +332,13 @@ func JiraToMD(str string) string { return str } -type JiraResolver struct { +type jiraResolver struct { JiraClient *jira.Client } // JiraMarkupMentionToEmail will replace JiraMarkup account mentions // with Display Name followed by parenthetical email addresses -func (j *JiraResolver) JiraMarkupMentionToEmail(str string) string { +func (j *jiraResolver) JiraMarkupMentionToEmail(str string) string { re := regexp.MustCompile(`(?m)(\[~accountid:)([a-zA-Z0-9-:]+)(\])`) rfunc := func(groups []string) string { // groups[0] is initial match @@ -214,16 +356,16 @@ func (j *JiraResolver) JiraMarkupMentionToEmail(str string) string { return groups[0] } - return DisplayJiraUser(jiraUser) + return displayJiraUser(jiraUser) } - return ReplaceAllStringSubmatchFunc(re, str, rfunc) + return replaceAllStringSubmatchFunc(re, str, rfunc) } -func DisplayJiraUser(jiraUser *jira.User) string { +func displayJiraUser(jiraUser *jira.User) string { return jiraUser.DisplayName + " (" + jiraUser.EmailAddress + ")" } -// ReplaceAllStringSubmatchFunc - Invokes Callback for Regex Replacement +// replaceAllStringSubmatchFunc - Invokes Callback for Regex Replacement // The repl function takes an unusual string slice argument: // - The 0th element is the complete match // - The following slice elements are the nth string found @@ -235,7 +377,7 @@ func DisplayJiraUser(jiraUser *jira.User) string { // Python: re.sub(pattern, callback, subject) // JavaScript: subject.replace(pattern, callback) // See https://gist.github.com/elliotchance/d419395aa776d632d897 -func ReplaceAllStringSubmatchFunc( +func replaceAllStringSubmatchFunc( re *regexp.Regexp, str string, repl func([]string) string, @@ -262,165 +404,9 @@ func ReplaceAllStringSubmatchFunc( } func JiraMarkupToGithubMarkdown(jiraClient *jira.Client, str string) string { - jiraAccountResolver := JiraResolver{ + jiraAccountResolver := jiraResolver{ JiraClient: jiraClient, } resolved := jiraAccountResolver.JiraMarkupMentionToEmail(str) return JiraToMD(resolved) } - -func AssignIssueToSelf(jiraClient *jira.Client, issue *jira.Issue, issueKey string) error { - self, _, selfErr := jiraClient.User.GetSelf() - if selfErr != nil { - return fmt.Errorf("unable to get myself: %+v", selfErr) - } - - if issue.Fields.Assignee == nil || self.AccountID != issue.Fields.Assignee.AccountID { - _, assignErr := jiraClient.Issue.UpdateAssignee(issueKey, self) - if assignErr != nil { - return fmt.Errorf("unable to assign %s to yourself: %+v", issueKey, assignErr) - } - fmt.Printf("Re-Assigned %s from %s\n", issueKey, DisplayJiraUser(issue.Fields.Assignee)) - } else { - fmt.Println("Already assigned to to you") - } - return nil -} - -// ParseJiraIssue - Sanitizes input -// + Trims leading and trailing whitespace -// + Trims a browse URL -// + Trims anything after ABCD-1234 -// If there is no jira issue match, returns empty string -func ParseJiraIssue(issueKey, host string) string { - issueKey = strings.TrimSpace(issueKey) - if issueKey == "" { - return issueKey - } - if strings.HasPrefix(issueKey, host) { - issueKey = strings.TrimPrefix(issueKey, host) - issueKey = strings.TrimPrefix(issueKey, "/browse/") - } - // This will remove everything after the ABCD-1234 - reg := regexp.MustCompile(`(.*/)?(?P[A-Za-z]+-[0-9]+).*`) - if reg.MatchString(issueKey) { - res := reg.ReplaceAllString(issueKey, "${Jira}") - return res - } - return "" -} - -// ParseJiraIssueFromBranch - Sanitizes input -// + Trims leading "feature/" (or whatever GIT_WORKON_PREFIX set to) -// + Trims leading and trailing whitespace -// + Trims a browse URL -// + Trims anything after ABCD-1234 -// If there is no jira issue match, returns whatever was passed -func ParseJiraIssueFromBranch(issueKey, host, branchPrefix string) string { - issueKey = strings.TrimSpace(issueKey) - issueKey = strings.TrimPrefix(issueKey, branchPrefix) - if issueKey == "" { - return issueKey - } - if strings.HasPrefix(issueKey, host) { - issueKey = strings.TrimPrefix(issueKey, host) - issueKey = strings.TrimPrefix(issueKey, "/browse/") - } - - res := TrimJira(issueKey) - if res != "" { - res = fmt.Sprintf("https://khanacademy.atlassian.net/browse/%s", res) - } - return res -} - -// TrimJira will remove everything before and after the last ABCD-1234 -// returns empty string if no jira issue is found -func TrimJira(s string) string { - var re *regexp.Regexp - var result string - re = regexp.MustCompile("([a-zA-Z]{1,4}-[1-9][0-9]{0,6})") - matches := re.FindAllStringSubmatch(s, -1) - for _, match := range matches { - for _, m := range match { - result = m - } - } - return result -} - -func MoveIssueToStatusByName(jiraClient *jira.Client, issue *jira.Issue, issueKey string, statusName string) error { - originalStatus := issue.Fields.Status.Name - if issue.Fields.Status.Name == statusName || - CaseInsensitiveContains(issue.Fields.Status.Name, statusName){ - return fmt.Errorf("issue is Already in Status %s\n", issue.Fields.Status.Name) - } - - err := transitionIssueByStatusName(jiraClient, issueKey, statusName) - if err != nil { - return err - } - issue, _, err = jiraClient.Issue.Get(issueKey, nil) - if err != nil { - return err - } - fmt.Printf("Issue %s Status successfully changed from: %s and set to: %+v\n", - issueKey, originalStatus, issue.Fields.Status.Name) - - return nil -} - -func transitionIssueByStatusName(jiraClient *jira.Client, issueKey string, statusName string) error { - var transitionID string - possibleTransitions, _, err := jiraClient.Issue.GetTransitions(issueKey) - if err != nil { - return err - } - for _, v := range possibleTransitions { - if strings.EqualFold(v.To.Name, statusName) { - transitionID = v.ID - break - } - } - // no exact match, so remove whitespace so that "ToDo" arg will match "TO DO" status - if transitionID == "" { - for _, v := range possibleTransitions { - if strings.EqualFold(RemoveWhiteSpace(v.To.Name), statusName) { - transitionID = v.ID - break - } - } - } - // still no match, so look for partial, so "Done" arg will match "Deployed / Done" - if transitionID == "" { - // substring match only if exact match fails - for _, v := range possibleTransitions { - if CaseInsensitiveContains(v.To.Name, statusName) { - transitionID = v.ID - break - } - } - } - - if transitionID == "" { - return fmt.Errorf("there does not appear to be a valid transition to %s", statusName) - } - _, err = jiraClient.Issue.DoTransition(issueKey, transitionID) - return err -} - -func RemoveWhiteSpace(str string) string { - var b strings.Builder - b.Grow(len(str)) - for _, ch := range str { - if !unicode.IsSpace(ch) { - b.WriteRune(ch) - } - } - return b.String() -} - -func CaseInsensitiveContains(s, substr string) bool { - s, substr = strings.ToUpper(s), strings.ToUpper(substr) - return strings.Contains(s, substr) -} \ No newline at end of file diff --git a/pkg/colors/colors.go b/pkg/colors/colors.go new file mode 100644 index 0000000..58311ea --- /dev/null +++ b/pkg/colors/colors.go @@ -0,0 +1,50 @@ +package colors + +import "strconv" + +// ANSIColor is a color (0-15) as defined by the ANSI Standard. +type ANSIColor int + +const ( + ANSIBlack ANSIColor = iota // "#000000" + ANSIRed // "#800000" + ANSIGreen // "#008000" + ANSIYellow // "#808000" + ANSIBlue // "#000080" + ANSIMagenta // "#800080" + ANSICyan // "#008080" + ANSIWhite // "#808080" + ANSIBrightBlack // "#c0c0c0" + ANSIBrightRed // "#ff0000" + ANSIBrightGreen // "#00ff00" + ANSIBrightYellow // "#ffff00" + ANSIBrightBlue // "#ff0000" + ANSIBrightMagenta // "#ff00ff" + ANSIBrightCyan // "#00ffff" + ANSIBrightWhite // "#ffffff" +) + +func (c ANSIColor) String() string { + return strconv.Itoa(int(c)) +} + +func (c ANSIColor) Hex() string { + return [...]string{ + "#000000", // ANSIBlack + "#800000", // ANSIRed + "#008000", // ANSIGreen + "#808000", // ANSIYellow + "#000080", // ANSIBlue + "#800080", // ANSIMagenta + "#008080", // ANSICyan + "#808080", // ANSIWhite + "#c0c0c0", // ANSIBrightBlack + "#ff0000", // ANSIBrightRed + "#00ff00", // ANSIBrightGreen + "#ffff00", // ANSIBrightYellow + "#ff0000", // ANSIBrightBlue + "#ff00ff", // ANSIBrightMagenta + "#00ffff", // ANSIBrightCyan + "#ffffff", // ANSIBrightWhite + }[c] +} diff --git a/pkg/git/wrapper.go b/pkg/git/wrapper.go new file mode 100644 index 0000000..72eb45d --- /dev/null +++ b/pkg/git/wrapper.go @@ -0,0 +1,40 @@ +package git + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" +) + +func command(out io.Writer, cmds []string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("git.exe", cmds...) + case "linux", "darwin": + cmd = exec.Command("git", cmds...) + default: + return fmt.Errorf("unsupported platform") + } + + cmd.Stdin = os.Stdin + if out != nil { + cmd.Stdout = out + cmd.Stderr = out + } + + return cmd.Run() +} + +func CurrentBranch() string { + var buf bytes.Buffer + err := command(&buf, []string{"symbolic-ref", "--short", "HEAD"}) + if err != nil { + return "" + } + return strings.TrimSpace(buf.String()) +} diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index d161fae..88d2eba 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -65,8 +65,6 @@ func NewBasicAuthHTTPClient(user, token string) *http.Client { hrt := NewHeaderRoundTripper(rt, header) hrt.BasicAuth(user, token) - - return &http.Client{ Transport: hrt, Timeout: 60 * time.Second, diff --git a/pkg/middleware/log.go b/pkg/middleware/log.go index 020bfc9..6758cbe 100644 --- a/pkg/middleware/log.go +++ b/pkg/middleware/log.go @@ -3,9 +3,10 @@ package middleware import ( "fmt" "io" - "moul.io/http2curl" "net/http" "time" + + "moul.io/http2curl" ) type LoggingRoundTripper struct { @@ -29,7 +30,7 @@ func (rt *LoggingRoundTripper) RoundTrip( defer func(begin time.Time) { var msg string - if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300){ + if resp != nil && (resp.StatusCode < 200 || resp.StatusCode >= 300) { msg = fmt.Sprintf( "method=%s host=%s path=%s status_code=%d took=%s\n", @@ -41,16 +42,13 @@ func (rt *LoggingRoundTripper) RoundTrip( ) if err != nil { fmt.Fprintf(rt.logger, "%s : %+v\n", msg, err) - } else { fmt.Fprintf(rt.logger, "%s\n", msg) } command, _ := http2curl.GetCurlCommand(req) fmt.Println(command) } - }(time.Now()) return rt.next.RoundTrip(req) } -