jira-cli/internal/config/generator.go

877 lines
21 KiB
Go

package config
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/core"
"github.com/spf13/viper"
"github.com/ankitpokhrel/jira-cli/api"
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
"github.com/ankitpokhrel/jira-cli/pkg/jira"
)
const (
// Dir is a jira-cli config directory.
Dir = ".jira"
// FileName is a jira-cli config file name.
FileName = ".config"
// FileType is a jira-cli config file extension.
FileType = "yml"
optionSearch = "[Search...]"
optionBack = "Go-back"
optionNone = "None"
lineBreak = "----------"
)
var (
// ErrSkip is returned when a user skips the config generation.
ErrSkip = fmt.Errorf("skipping config generation")
// ErrUnexpectedResponseFormat is returned if the response data is in unexpected format.
ErrUnexpectedResponseFormat = fmt.Errorf("unexpected response format")
)
// projectConf is a trimmed down version of jira.Project.
type projectConf struct {
Key string `json:"key"`
Type string `json:"type"`
}
// issueTypeFieldConf is a trimmed down version of jira.IssueTypeField.
type issueTypeFieldConf struct {
Name string `yaml:"name"`
Key string `yaml:"key"`
Schema struct {
DataType string `yaml:"datatype"`
Items string `yaml:"items,omitempty"`
}
}
// JiraCLIMTLSConfig is an authtype specific config.
type JiraCLIMTLSConfig struct {
CaCert string
ClientCert string
ClientKey string
}
// JiraCLIConfig is a Jira CLI config.
type JiraCLIConfig struct {
Installation string
Server string
AuthType string
Login string
Project string
Board string
Force bool
Insecure bool
MTLS JiraCLIMTLSConfig
}
// JiraCLIConfigGenerator is a Jira CLI config generator.
type JiraCLIConfigGenerator struct {
usrCfg *JiraCLIConfig
value struct {
installation string
server string
version struct {
major, minor, patch int
}
login string
authType jira.AuthType
project *projectConf
board *jira.Board
epic *jira.Epic
issueTypes []*jira.IssueType
customFields []*issueTypeFieldConf
mtls struct {
caCert, clientCert, clientKey string
}
timezone string
}
jiraClient *jira.Client
projectSuggestions []string
boardSuggestions []string
projectsMap map[string]*projectConf
boardsMap map[string]*jira.Board
}
// NewJiraCLIConfigGenerator creates a new Jira CLI config.
func NewJiraCLIConfigGenerator(cfg *JiraCLIConfig) *JiraCLIConfigGenerator {
gen := JiraCLIConfigGenerator{
usrCfg: cfg,
projectsMap: make(map[string]*projectConf),
boardsMap: make(map[string]*jira.Board),
}
return &gen
}
// Generate generates the config file.
//
//nolint:gocyclo
func (c *JiraCLIConfigGenerator) Generate() (string, error) {
var cfgFile string
if cfgFile = viper.ConfigFileUsed(); cfgFile == "" {
home, err := cmdutil.GetConfigHome()
if err != nil {
return "", err
}
cfgFile = fmt.Sprintf("%s/%s/%s.%s", home, Dir, FileName, FileType)
} else {
isExtValid := func() bool {
cf := strings.ToLower(cfgFile)
for _, ext := range []string{FileType, "yaml"} {
if strings.HasSuffix(cf, fmt.Sprintf(".%s", ext)) {
return true
}
}
return false
}
// Enforce .yml extension.
if !isExtValid() {
cfgFile = fmt.Sprintf("%s.%s", cfgFile, FileType)
}
}
cfgExists := func() bool {
s := cmdutil.Info("Checking configuration...")
defer s.Stop()
return Exists(cfgFile)
}()
if !c.usrCfg.Force && cfgExists && !shallOverwrite() {
return "", ErrSkip
}
if err := c.configureInstallationType(); err != nil {
return "", err
}
if c.value.installation == jira.InstallationTypeLocal {
if err := c.configureLocalAuthType(); err != nil {
return "", err
}
}
if c.usrCfg.AuthType != "" {
c.value.authType = jira.AuthType(c.usrCfg.AuthType)
}
if c.value.authType == jira.AuthTypeMTLS {
if err := c.configureMTLS(); err != nil {
return "", err
}
}
if err := c.configureServerAndLoginDetails(); err != nil {
return "", err
}
if c.value.installation == jira.InstallationTypeLocal {
if err := c.configureServerMeta(c.value.server, c.value.login); err != nil {
return "", err
}
}
if err := c.configureProjectAndBoardDetails(); err != nil {
return "", err
}
if err := c.configureMetadata(); err != nil {
return "", err
}
if err := func() error {
s := cmdutil.Info("Creating new configuration...")
defer s.Stop()
return create(cfgFile)
}(); err != nil {
return "", err
}
return c.write(cfgFile)
}
func (c *JiraCLIConfigGenerator) configureInstallationType() error {
switch c.usrCfg.Installation {
case strings.ToLower(jira.InstallationTypeCloud):
c.value.installation = jira.InstallationTypeCloud
case strings.ToLower(jira.InstallationTypeLocal):
c.value.installation = jira.InstallationTypeLocal
default:
qs := &survey.Select{
Message: "Installation type:",
Help: "Is this a cloud installation or an on-premise (local) installation.",
Options: []string{"Cloud", "Local"},
Default: "Cloud",
}
var installation string
if err := survey.AskOne(qs, &installation); err != nil {
return err
}
c.value.installation = installation
}
return nil
}
func (c *JiraCLIConfigGenerator) configureLocalAuthType() error {
authType := c.usrCfg.AuthType
if c.usrCfg.AuthType == "" {
qs := &survey.Select{
Message: "Authentication type:",
Help: `Authentication type coud be: basic (login), bearer (PAT) or mtls (client certs)
? If you are using your login credentials, the auth type is probably 'basic' (most common for local installation)
? If you are using a personal access token, the auth type is probably 'bearer'`,
Options: []string{"basic", "bearer", "mtls"},
Default: "basic",
}
if err := survey.AskOne(qs, &authType); err != nil {
return err
}
}
switch authType {
case jira.AuthTypeBearer.String():
c.value.authType = jira.AuthTypeBearer
case jira.AuthTypeMTLS.String():
c.value.authType = jira.AuthTypeMTLS
default:
c.value.authType = jira.AuthTypeBasic
}
return nil
}
func (c *JiraCLIConfigGenerator) configureMTLS() error {
var qs []*survey.Question
c.value.mtls.caCert = c.usrCfg.MTLS.CaCert
c.value.mtls.clientCert = c.usrCfg.MTLS.ClientCert
c.value.mtls.clientKey = c.usrCfg.MTLS.ClientKey
getIfEmpty := func(conf, name, msg, help string) {
if conf != "" {
return
}
qs = append(qs, &survey.Question{
Name: name,
Prompt: &survey.Input{
Message: msg,
Help: help,
},
})
}
getIfEmpty(c.value.mtls.caCert, "cacert", "CA Certificate", "Local path to CA Certificate for your `server`")
getIfEmpty(c.value.mtls.clientCert, "clientcert", "Client Certificate", "Local path to your client certificate")
getIfEmpty(c.value.mtls.clientKey, "clientkey", "Client Key", "Local path to your client key")
if len(qs) > 0 {
ans := struct {
CaCert string
ClientCert string
ClientKey string
}{}
if err := survey.Ask(qs, &ans); err != nil {
return err
}
if ans.CaCert != "" {
c.value.mtls.caCert = ans.CaCert
}
if ans.ClientCert != "" {
c.value.mtls.clientCert = ans.ClientCert
}
if ans.ClientKey != "" {
c.value.mtls.clientKey = ans.ClientKey
}
}
return nil
}
//nolint:gocyclo
func (c *JiraCLIConfigGenerator) configureServerAndLoginDetails() error {
var qs []*survey.Question
c.value.server = c.usrCfg.Server
c.value.login = c.usrCfg.Login
if c.usrCfg.Server == "" {
qs = append(qs, &survey.Question{
Name: "server",
Prompt: &survey.Input{
Message: "Link to Jira server:",
Help: "This is a link to your jira server, eg: https://company.atlassian.net",
},
Validate: func(val interface{}) error {
errInvalidURL := fmt.Errorf("not a valid URL")
str, ok := val.(string)
if !ok {
return errInvalidURL
}
u, err := url.Parse(str)
if err != nil || u.Scheme == "" || u.Host == "" {
return errInvalidURL
}
if u.Scheme != "http" && u.Scheme != "https" {
return errInvalidURL
}
return nil
},
})
}
if c.usrCfg.Login == "" {
switch c.value.installation {
case jira.InstallationTypeCloud:
qs = append(qs, &survey.Question{
Name: "login",
Prompt: &survey.Input{
Message: "Login email:",
Help: "This is the email you use to login to your jira account.",
},
Validate: func(val interface{}) error {
var (
emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9]" +
"(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
errInvalidEmail = fmt.Errorf("not a valid email")
)
str, ok := val.(string)
if !ok {
return errInvalidEmail
}
if len(str) < 3 || len(str) > 254 {
return errInvalidEmail
}
if !emailRegex.MatchString(str) {
return errInvalidEmail
}
return nil
},
})
case jira.InstallationTypeLocal:
qs = append(qs, &survey.Question{
Name: "login",
Prompt: &survey.Input{
Message: "Login username:",
Help: "This is the username you use to login to your jira account.",
},
Validate: func(val interface{}) error {
errInvalidUser := fmt.Errorf("not a valid user")
str, ok := val.(string)
if !ok {
return errInvalidUser
}
if len(str) < 3 || len(str) > 254 {
return errInvalidUser
}
return nil
},
})
}
}
if len(qs) > 0 {
ans := struct {
Server string
Login string
}{}
if err := survey.Ask(qs, &ans); err != nil {
return err
}
if ans.Server != "" {
c.value.server = ans.Server
}
if ans.Login != "" {
c.value.login = ans.Login
}
}
return c.verifyLoginDetails(c.value.server, c.value.login)
}
func (c *JiraCLIConfigGenerator) verifyLoginDetails(server, login string) error {
s := cmdutil.Info("Verifying login details...")
defer s.Stop()
server = strings.TrimRight(server, "/")
c.jiraClient = api.Client(jira.Config{
Server: server,
Login: login,
Insecure: &c.usrCfg.Insecure,
AuthType: &c.value.authType,
Debug: viper.GetBool("debug"),
MTLSConfig: jira.MTLSConfig{
CaCert: c.value.mtls.caCert,
ClientCert: c.value.mtls.clientCert,
ClientKey: c.value.mtls.clientKey,
},
})
ret, err := c.jiraClient.Me()
if err != nil {
return err
}
if c.value.authType == jira.AuthTypeBearer {
login = ret.Login
}
c.value.server = server
c.value.login = login
c.value.timezone = ret.Timezone
return nil
}
func (c *JiraCLIConfigGenerator) configureServerMeta(server, login string) error {
s := cmdutil.Info("Fetching server details...")
defer s.Stop()
server = strings.TrimRight(server, "/")
c.jiraClient = api.Client(jira.Config{
Server: server,
Login: login,
Insecure: &c.usrCfg.Insecure,
AuthType: &c.value.authType,
Debug: viper.GetBool("debug"),
MTLSConfig: jira.MTLSConfig{
CaCert: c.value.mtls.caCert,
ClientCert: c.value.mtls.clientCert,
ClientKey: c.value.mtls.clientKey,
},
})
info, err := c.jiraClient.ServerInfo()
if err != nil {
return err
}
if len(info.VersionNumbers) == 3 {
c.value.version.major = info.VersionNumbers[0]
c.value.version.minor = info.VersionNumbers[1]
c.value.version.patch = info.VersionNumbers[2]
}
return nil
}
//nolint:gocyclo
func (c *JiraCLIConfigGenerator) configureProjectAndBoardDetails() error {
project := c.usrCfg.Project
board := c.usrCfg.Board
if err := c.getProjectSuggestions(); err != nil {
return err
}
if c.usrCfg.Project == "" {
projectPrompt := survey.Select{
Message: "Default project:",
Help: "This is your project key that you want to access by default when using the cli.",
Options: c.projectSuggestions,
}
if err := survey.AskOne(&projectPrompt, &project, survey.WithValidator(survey.Required)); err != nil {
return err
}
}
c.value.project = c.projectsMap[strings.ToLower(project)]
if c.value.project == nil {
return fmt.Errorf("project not found\n Please check the project key and try again")
}
if err := c.getBoardSuggestions(project); err != nil {
return err
}
defaultBoardSuggestions := c.boardSuggestions
if c.usrCfg.Board == "" {
for {
boardPrompt := &survey.Question{
Name: "",
Prompt: &survey.Select{
Message: "Default board:",
Help: "This is your default project board that you want to access by default when using the cli.",
Options: c.boardSuggestions,
},
Validate: func(val interface{}) error {
errInvalidSelection := fmt.Errorf("invalid selection")
ans, ok := val.(core.OptionAnswer)
if !ok {
return errInvalidSelection
}
if ans.Value == "" || ans.Value == lineBreak {
return errInvalidSelection
}
return nil
},
}
if err := survey.Ask([]*survey.Question{boardPrompt}, &board, survey.WithValidator(survey.Required)); err != nil {
return err
}
if board != optionBack && board != optionSearch {
break
}
if board == optionBack {
c.boardSuggestions = defaultBoardSuggestions
}
if board == optionSearch {
kw, err := c.getSearchKeyword()
if err != nil {
return err
}
if err := c.searchAndAssignBoard(project, kw); err != nil {
return err
}
}
}
}
c.value.board = c.boardsMap[strings.ToLower(board)]
if c.value.board == nil && !strings.EqualFold(board, optionNone) {
var suggest string
if len(defaultBoardSuggestions) > 2 {
suggest = strings.Join(defaultBoardSuggestions[2:], ", ")
} else {
suggest = strings.Join(defaultBoardSuggestions, ", ")
}
return fmt.Errorf(
"board not found\n Boards available for the project '%s' are '%s'",
c.value.project.Key,
suggest,
)
}
return nil
}
func (*JiraCLIConfigGenerator) getSearchKeyword() (string, error) {
var ans string
qs := &survey.Question{
Name: "board",
Prompt: &survey.Input{
Message: "Search board:",
Help: "Type board name to search",
},
Validate: func(val interface{}) error {
errInvalidKeyword := fmt.Errorf("enter atleast 3 characters to search")
str, ok := val.(string)
if !ok {
return errInvalidKeyword
}
if len(str) < 3 {
return errInvalidKeyword
}
return nil
},
}
if err := survey.Ask([]*survey.Question{qs}, &ans); err != nil {
return "", err
}
return ans, nil
}
func (c *JiraCLIConfigGenerator) searchAndAssignBoard(project, keyword string) error {
resp, err := c.jiraClient.BoardSearch(project, keyword)
if err != nil {
return err
}
c.boardSuggestions = []string{}
for _, board := range resp.Boards {
c.boardsMap[strings.ToLower(board.Name)] = board
c.boardSuggestions = append(c.boardSuggestions, board.Name)
}
c.boardSuggestions = append(c.boardSuggestions, lineBreak, optionSearch, optionBack)
return nil
}
func (c *JiraCLIConfigGenerator) configureMetadata() error {
var err error
//nolint:gomnd
isV9Compatible := c.value.version.major >= 9 || (c.value.version.major == 8 && c.value.version.minor > 4)
if c.value.installation == jira.InstallationTypeLocal && isV9Compatible {
err = c.configureIssueTypesForJiraServerV9()
} else {
err = c.configureIssueTypes()
}
if err != nil {
return err
}
return c.configureFields()
}
func (c *JiraCLIConfigGenerator) configureIssueTypes() error {
s := cmdutil.Info("Configuring metadata. Please wait...")
defer s.Stop()
meta, err := c.jiraClient.GetCreateMeta(&jira.CreateMetaRequest{
Projects: c.value.project.Key,
Expand: "projects.issuetypes.fields",
})
if err != nil {
return err
}
if len(meta.Projects) == 0 || len(meta.Projects[0].IssueTypes) == 0 {
return ErrUnexpectedResponseFormat
}
issueTypes := make([]*jira.IssueType, 0, len(meta.Projects[0].IssueTypes))
for _, it := range meta.Projects[0].IssueTypes {
issueType := jira.IssueType{
ID: it.ID,
Name: it.Name,
Handle: it.Handle,
Subtask: it.Subtask,
}
issueTypes = append(issueTypes, &issueType)
}
c.value.issueTypes = issueTypes
return nil
}
func (c *JiraCLIConfigGenerator) configureIssueTypesForJiraServerV9() error {
s := cmdutil.Info("Configuring metadata. Please wait...")
defer s.Stop()
meta, err := c.jiraClient.GetCreateMetaForJiraServerV9(&jira.CreateMetaRequest{
Projects: c.value.project.Key,
Expand: "projects.issuetypes.fields",
})
if err != nil {
return err
}
if len(meta.Values) == 0 {
return ErrUnexpectedResponseFormat
}
issueTypes := make([]*jira.IssueType, 0, len(meta.Values))
for _, it := range meta.Values {
issueType := jira.IssueType{
ID: it.ID,
Name: it.Name,
Subtask: it.Subtask,
}
issueTypes = append(issueTypes, &issueType)
}
c.value.issueTypes = issueTypes
return nil
}
func (c *JiraCLIConfigGenerator) configureFields() error {
customFields := make([]*issueTypeFieldConf, 0)
fields, err := c.jiraClient.GetField()
if err != nil {
return err
}
var epic jira.Epic
for _, field := range fields {
if !field.Custom {
continue
}
if field.Name == jira.EpicFieldName {
epic.Name = field.ID
continue
}
if field.Name == jira.EpicFieldLink {
epic.Link = field.ID
continue
}
customFields = append(customFields, &issueTypeFieldConf{
Name: field.Name,
Key: field.ID,
Schema: struct {
DataType string `yaml:"datatype"`
Items string `yaml:"items,omitempty"`
}{
DataType: field.Schema.DataType,
Items: field.Schema.Items,
},
})
}
c.value.epic = &epic
c.value.customFields = customFields
return nil
}
func (c *JiraCLIConfigGenerator) write(path string) (string, error) {
name := func() string {
ext := filepath.Ext(path)
if ext == "" {
return path
}
return strings.TrimSuffix(filepath.Base(path), ext)
}
config := viper.New()
config.AddConfigPath(filepath.Dir(path))
config.SetConfigName(name())
config.SetConfigType(FileType)
if c.usrCfg.Insecure {
config.Set("insecure", c.usrCfg.Insecure)
}
config.Set("installation", c.value.installation)
config.Set("server", c.value.server)
config.Set("login", c.value.login)
config.Set("project", c.value.project)
config.Set("epic", c.value.epic)
config.Set("issue.types", c.value.issueTypes)
config.Set("issue.fields.custom", c.value.customFields)
config.Set("auth_type", c.value.authType.String())
config.Set("timezone", c.value.timezone)
// MTLS.
if c.value.mtls.caCert != "" {
config.Set("mtls.ca_cert", c.value.mtls.caCert)
config.Set("mtls.client_cert", c.value.mtls.clientCert)
config.Set("mtls.client_key", c.value.mtls.clientKey)
}
// Jira version.
if c.value.version.major > 0 {
config.Set("version.major", c.value.version.major)
config.Set("version.minor", c.value.version.minor)
config.Set("version.patch", c.value.version.patch)
}
if c.value.board != nil {
config.Set("board", c.value.board)
} else {
config.Set("board", "")
}
if err := config.WriteConfig(); err != nil {
return "", err
}
return path, nil
}
func (c *JiraCLIConfigGenerator) getProjectSuggestions() error {
s := cmdutil.Info("Fetching projects...")
defer s.Stop()
projects, err := c.jiraClient.Project()
if err != nil {
return err
}
for _, project := range projects {
c.projectsMap[strings.ToLower(project.Key)] = &projectConf{
Key: project.Key,
Type: project.Type,
}
c.projectSuggestions = append(c.projectSuggestions, project.Key)
}
return nil
}
func (c *JiraCLIConfigGenerator) getBoardSuggestions(project string) error {
s := cmdutil.Info(fmt.Sprintf("Fetching boards for project '%s'...", project))
defer s.Stop()
resp, err := c.jiraClient.Boards(project, "")
if err != nil {
if c.value.installation == jira.InstallationTypeCloud {
return err
}
// We don't care about the error in the local instance since board API may not exist if agile-addon is not installed.
// The only option available for board selection, in this case, is "None" if not passed directly from the flag.
c.boardSuggestions = append(c.boardSuggestions, optionNone)
return nil
}
c.boardSuggestions = append(c.boardSuggestions, optionSearch, lineBreak)
for _, board := range resp.Boards {
c.boardsMap[strings.ToLower(board.Name)] = board
c.boardSuggestions = append(c.boardSuggestions, board.Name)
}
c.boardSuggestions = append(c.boardSuggestions, optionNone)
return nil
}
// Exists checks if the file exist.
func Exists(file string) bool {
if file == "" {
return false
}
if _, err := os.Stat(file); os.IsNotExist(err) {
return false
}
return true
}
func shallOverwrite() bool {
var ans bool
prompt := &survey.Confirm{
Message: "Config already exist. Do you want to overwrite?",
}
if err := survey.AskOne(prompt, &ans); err != nil {
return false
}
return ans
}
func create(file string) error {
const perm = 0o700
path := filepath.Dir(file)
if !Exists(path) {
if err := os.MkdirAll(path, perm); err != nil {
return err
}
}
if Exists(file) {
if err := os.Rename(file, file+".bkp"); err != nil {
return err
}
}
f, err := os.Create(file)
defer func() { _ = f.Close() }()
return err
}