From fe3d5c95e4be5874066402b5463ada34894c7f01 Mon Sep 17 00:00:00 2001 From: Michael Muré Date: Sat, 15 Feb 2020 02:55:19 +0100 Subject: bridges: massive refactor - automatic flag validation and warning - generalized prompt - cleanups --- bridge/core/auth/options.go | 11 +- bridge/core/bridge.go | 31 +++--- bridge/core/interfaces.go | 3 + bridge/core/params.go | 36 +++++++ bridge/github/config.go | 198 +++++++++++------------------------ bridge/github/export.go | 16 +-- bridge/github/export_test.go | 8 +- bridge/github/github.go | 10 +- bridge/github/import.go | 2 +- bridge/github/import_test.go | 4 +- bridge/gitlab/config.go | 208 ++++++++----------------------------- bridge/gitlab/export.go | 14 +-- bridge/gitlab/export_test.go | 8 +- bridge/gitlab/gitlab.go | 10 +- bridge/gitlab/import.go | 10 +- bridge/gitlab/import_test.go | 4 +- bridge/launchpad/config.go | 23 ++-- bridge/launchpad/launchpad.go | 4 +- commands/bridge_configure.go | 22 ++-- doc/man/git-bug-bridge-configure.1 | 25 +++-- doc/md/git-bug_bridge_configure.md | 20 ++-- input/prompt.go | 182 +++++++++++++++++++++++++++++++- misc/bash_completion/git-bug | 12 ++- misc/powershell_completion/git-bug | 24 +++-- misc/zsh_completion/git-bug | 13 +-- 25 files changed, 468 insertions(+), 430 deletions(-) create mode 100644 bridge/core/params.go diff --git a/bridge/core/auth/options.go b/bridge/core/auth/options.go index 74189874..1d8c44d1 100644 --- a/bridge/core/auth/options.go +++ b/bridge/core/auth/options.go @@ -2,7 +2,7 @@ package auth type options struct { target string - kind CredentialKind + kind map[CredentialKind]interface{} meta map[string]string } @@ -21,7 +21,8 @@ func (opts *options) Match(cred Credential) bool { return false } - if opts.kind != "" && cred.Kind() != opts.kind { + _, has := opts.kind[cred.Kind()] + if len(opts.kind) > 0 && !has { return false } @@ -40,9 +41,13 @@ func WithTarget(target string) Option { } } +// WithKind match credentials with the given kind. Can be specified multiple times. func WithKind(kind CredentialKind) Option { return func(opts *options) { - opts.kind = kind + if opts.kind == nil { + opts.kind = make(map[CredentialKind]interface{}) + } + opts.kind[kind] = nil } } diff --git a/bridge/core/bridge.go b/bridge/core/bridge.go index ac0d47d7..62fd70f6 100644 --- a/bridge/core/bridge.go +++ b/bridge/core/bridge.go @@ -4,6 +4,7 @@ package core import ( "context" "fmt" + "os" "reflect" "regexp" "sort" @@ -30,18 +31,6 @@ const ( var bridgeImpl map[string]reflect.Type var bridgeLoginMetaKey map[string]string -// BridgeParams holds parameters to simplify the bridge configuration without -// having to make terminal prompts. -type BridgeParams struct { - Owner string // owner of the repo (Github) - Project string // name of the repo (Github, Launchpad) - URL string // complete URL of a repo (Github, Gitlab, Launchpad) - BaseURL string // base URL for self-hosted instance ( Gitlab) - CredPrefix string // ID prefix of the credential to use (Github, Gitlab) - TokenRaw string // pre-existing token to use (Github, Gitlab) - Login string // username for the passed credential (Github, Gitlab) -} - // Bridge is a wrapper around a BridgeImpl that will bind low-level // implementation with utility code to provide high-level functions. type Bridge struct { @@ -220,6 +209,8 @@ func RemoveBridge(repo repository.RepoConfig, name string) error { // Configure run the target specific configuration process func (b *Bridge) Configure(params BridgeParams) error { + validateParams(params, b.impl) + conf, err := b.impl.Configure(b.repo, params) if err != nil { return err @@ -234,6 +225,22 @@ func (b *Bridge) Configure(params BridgeParams) error { return b.storeConfig(conf) } +func validateParams(params BridgeParams, impl BridgeImpl) { + validParams := impl.ValidParams() + + paramsValue := reflect.ValueOf(params) + paramsType := paramsValue.Type() + + for i := 0; i < paramsValue.NumField(); i++ { + name := paramsType.Field(i).Name + val := paramsValue.Field(i).Interface().(string) + _, valid := validParams[name] + if val != "" && !valid { + _, _ = fmt.Fprintln(os.Stderr, params.fieldWarning(name, impl.Target())) + } + } +} + func (b *Bridge) storeConfig(conf Configuration) error { for key, val := range conf { storeKey := fmt.Sprintf("git-bug.bridge.%s.%s", b.Name, key) diff --git a/bridge/core/interfaces.go b/bridge/core/interfaces.go index ab2f3977..63340a95 100644 --- a/bridge/core/interfaces.go +++ b/bridge/core/interfaces.go @@ -18,6 +18,9 @@ type BridgeImpl interface { // credentials. LoginMetaKey() string + // The set of the BridgeParams fields supported + ValidParams() map[string]interface{} + // Configure handle the user interaction and return a key/value configuration // for future use Configure(repo *cache.RepoCache, params BridgeParams) (Configuration, error) diff --git a/bridge/core/params.go b/bridge/core/params.go new file mode 100644 index 00000000..e398b81a --- /dev/null +++ b/bridge/core/params.go @@ -0,0 +1,36 @@ +package core + +import "fmt" + +// BridgeParams holds parameters to simplify the bridge configuration without +// having to make terminal prompts. +type BridgeParams struct { + URL string // complete URL of a repo (Github, Gitlab, , Launchpad) + BaseURL string // base URL for self-hosted instance ( Gitlab, Jira, ) + Login string // username for the passed credential (Github, Gitlab, Jira, ) + CredPrefix string // ID prefix of the credential to use (Github, Gitlab, Jira, ) + TokenRaw string // pre-existing token to use (Github, Gitlab, , ) + Owner string // owner of the repo (Github, , , ) + Project string // name of the repo or project key (Github, , Jira, Launchpad) +} + +func (BridgeParams) fieldWarning(field string, target string) string { + switch field { + case "URL": + return fmt.Sprintf("warning: --url is ineffective for a %s bridge", target) + case "BaseURL": + return fmt.Sprintf("warning: --base-url is ineffective for a %s bridge", target) + case "Login": + return fmt.Sprintf("warning: --login is ineffective for a %s bridge", target) + case "CredPrefix": + return fmt.Sprintf("warning: --credential is ineffective for a %s bridge", target) + case "TokenRaw": + return fmt.Sprintf("warning: tokens are ineffective for a %s bridge", target) + case "Owner": + return fmt.Sprintf("warning: --owner is ineffective for a %s bridge", target) + case "Project": + return fmt.Sprintf("warning: --project is ineffective for a %s bridge", target) + default: + panic("unknown field") + } +} diff --git a/bridge/github/config.go b/bridge/github/config.go index afb8086c..9167ac26 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -1,7 +1,6 @@ package github import ( - "bufio" "bytes" "context" "encoding/json" @@ -10,14 +9,11 @@ import ( "io/ioutil" "math/rand" "net/http" - "os" "regexp" "sort" - "strconv" "strings" "time" - text "github.com/MichaelMure/go-term-text" "github.com/pkg/errors" "github.com/MichaelMure/git-bug/bridge/core" @@ -25,19 +21,24 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/colors" ) var ( ErrBadProjectURL = errors.New("bad project url") ) -func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.BaseURL != "" { - fmt.Println("warning: --base-url is ineffective for a Github bridge") +func (g *Github) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "Login": nil, + "CredPrefix": nil, + "TokenRaw": nil, + "Owner": nil, + "Project": nil, } +} - conf := make(core.Configuration) +func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var owner string var project string @@ -121,9 +122,10 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyOwner] = owner - conf[keyProject] = project + conf[confKeyOwner] = owner + conf[confKeyProject] = project err = g.ValidateConfig(conf) if err != nil { @@ -141,25 +143,25 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor return conf, core.FinishConfig(repo, metaKeyGithubLogin, login) } -func (*Github) ValidateConfig(conf core.Configuration) error { +func (Github) ValidateConfig(conf core.Configuration) error { if v, ok := conf[core.ConfigKeyTarget]; !ok { return fmt.Errorf("missing %s key", core.ConfigKeyTarget) } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyOwner]; !ok { - return fmt.Errorf("missing %s key", keyOwner) + if _, ok := conf[confKeyOwner]; !ok { + return fmt.Errorf("missing %s key", confKeyOwner) } - if _, ok := conf[keyProject]; !ok { - return fmt.Errorf("missing %s key", keyProject) + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) } return nil } -func usernameValidator(name string, value string) (string, error) { +func usernameValidator(_ string, value string) (string, error) { ok, err := validateUsername(value) if err != nil { return "", err @@ -241,67 +243,31 @@ func randomFingerprint() string { } func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) { - for { - creds, err := auth.List(repo, - auth.WithTarget(target), - auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyLogin, login), - ) - if err != nil { - return nil, err - } - - fmt.Println() - fmt.Println("[1]: enter my token") - fmt.Println("[2]: interactive token creation") - - if len(creds) > 0 { - sort.Sort(auth.ById(creds)) - - fmt.Println() - fmt.Println("Existing tokens for Github:") - for i, cred := range creds { - token := cred.(*auth.Token) - fmt.Printf("[%d]: %s => %s (login: %s, %s)\n", - i+3, - colors.Cyan(token.ID().Human()), - colors.Red(text.TruncateMax(token.Value, 10)), - token.Metadata()[auth.MetaKeyLogin], - token.CreateTime().Format(time.RFC822), - ) - } - } - - fmt.Println() - fmt.Print("Select option: ") + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + ) + if err != nil { + return nil, err + } - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Println() + cred, err := input.PromptCredentialWithInteractive(target, "token", creds) + switch err { + case nil: + return cred, nil + case input.ErrDirectPrompt: + return promptToken() + case input.ErrInteractiveCreation: + value, err := loginAndRequestToken(login, owner, project) if err != nil { return nil, err } - - line = strings.TrimSpace(line) - index, err := strconv.Atoi(line) - if err != nil || index < 1 || index > len(creds)+2 { - fmt.Println("invalid input") - continue - } - - switch index { - case 1: - return promptToken() - case 2: - value, err := loginAndRequestToken(login, owner, project) - if err != nil { - return nil, err - } - token := auth.NewToken(target, value) - token.SetMetadata(auth.MetaKeyLogin, login) - return token, nil - default: - return creds[index-3], nil - } + token := auth.NewToken(target, value) + token.SetMetadata(auth.MetaKeyLogin, login) + return token, nil + default: + return nil, err } } @@ -413,73 +379,25 @@ func loginAndRequestToken(login, owner, project string) (string, error) { } func promptURL(repo repository.RepoCommon) (string, string, error) { - // remote suggestions - remotes, err := repo.GetRemotes() + validRemotes, err := getValidGithubRemoteURLs(repo) if err != nil { return "", "", err } - validRemotes := getValidGithubRemoteURLs(remotes) - if len(validRemotes) > 0 { - for { - fmt.Println("\nDetected projects:") - - // print valid remote github urls - for i, remote := range validRemotes { - fmt.Printf("[%d]: %v\n", i+1, remote) - } - - fmt.Printf("\n[0]: Another project\n\n") - fmt.Printf("Select option: ") - - 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(validRemotes) { - fmt.Println("invalid input") - continue - } - - // if user want to enter another project url break this loop - if index == 0 { - break - } - - // get owner and project with index - owner, project, _ := splitURL(validRemotes[index-1]) - return owner, project, nil - } - } - - // manually enter github url - for { - fmt.Print("Github project URL: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", "", err - } - - line = strings.TrimSpace(line) - if line == "" { - fmt.Println("URL is empty") - continue - } - - // get owner and project from url - owner, project, err := splitURL(line) + validator := func(name, value string) (string, error) { + _, _, err := splitURL(value) if err != nil { - fmt.Println(err) - continue + return err.Error(), nil } + return "", nil + } - return owner, project, nil + url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator) + if err != nil { + return "", "", err } + + return splitURL(url) } // splitURL extract the owner and project from a github repository URL. It will remove the @@ -488,10 +406,7 @@ func promptURL(repo repository.RepoCommon) (string, string, error) { func splitURL(url string) (owner string, project string, err error) { cleanURL := strings.TrimSuffix(url, ".git") - re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`) - if err != nil { - panic("regexp compile:" + err.Error()) - } + re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`) res := re.FindStringSubmatch(cleanURL) if res == nil { @@ -503,7 +418,12 @@ func splitURL(url string) (owner string, project string, err error) { return } -func getValidGithubRemoteURLs(remotes map[string]string) []string { +func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) { + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + urls := make([]string, 0, len(remotes)) for _, url := range remotes { // split url can work again with shortURL @@ -516,7 +436,7 @@ func getValidGithubRemoteURLs(remotes map[string]string) []string { sort.Strings(urls) - return urls + return urls, nil } func validateUsername(username string) (bool, error) { diff --git a/bridge/github/export.go b/bridge/github/export.go index c363e188..6cee4188 100644 --- a/bridge/github/export.go +++ b/bridge/github/export.go @@ -139,8 +139,8 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, ge.repositoryID, err = getRepositoryNodeID( ctx, ge.defaultToken, - ge.conf[keyOwner], - ge.conf[keyProject], + ge.conf[confKeyOwner], + ge.conf[confKeyProject], ) if err != nil { return nil, err @@ -187,7 +187,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - ge.exportBug(ctx, b, since, out) + ge.exportBug(ctx, b, out) } } } @@ -197,7 +197,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, } // exportBug publish bugs and related events -func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) { +func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) { snapshot := b.Snapshot() var bugUpdated bool @@ -238,7 +238,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc } // ignore issue coming from other repositories - if owner != ge.conf[keyOwner] && project != ge.conf[keyProject] { + if owner != ge.conf[confKeyOwner] && project != ge.conf[confKeyProject] { out <- core.NewExportNothing(b.Id(), fmt.Sprintf("skipping issue from url:%s", githubURL)) return } @@ -481,8 +481,8 @@ func markOperationAsExported(b *cache.BugCache, target entity.Id, githubID, gith func (ge *githubExporter) cacheGithubLabels(ctx context.Context, gc *githubv4.Client) error { variables := map[string]interface{}{ - "owner": githubv4.String(ge.conf[keyOwner]), - "name": githubv4.String(ge.conf[keyProject]), + "owner": githubv4.String(ge.conf[confKeyOwner]), + "name": githubv4.String(ge.conf[confKeyProject]), "first": githubv4.Int(10), "after": (*githubv4.String)(nil), } @@ -526,7 +526,7 @@ func (ge *githubExporter) getLabelID(gc *githubv4.Client, label string) (string, // NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels // see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview func (ge *githubExporter) createGithubLabel(ctx context.Context, label, color string) (string, error) { - url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject]) + url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[confKeyOwner], ge.conf[confKeyProject]) client := &http.Client{} params := struct { diff --git a/bridge/github/export_test.go b/bridge/github/export_test.go index 56e29835..a25d56d7 100644 --- a/bridge/github/export_test.go +++ b/bridge/github/export_test.go @@ -188,8 +188,8 @@ func TestPushPull(t *testing.T) { // initialize exporter exporter := &githubExporter{} err = exporter.Init(backend, core.Configuration{ - keyOwner: envUser, - keyProject: projectName, + confKeyOwner: envUser, + confKeyProject: projectName, }) require.NoError(t, err) @@ -216,8 +216,8 @@ func TestPushPull(t *testing.T) { importer := &githubImporter{} err = importer.Init(backend, core.Configuration{ - keyOwner: envUser, - keyProject: projectName, + confKeyOwner: envUser, + confKeyProject: projectName, }) require.NoError(t, err) diff --git a/bridge/github/github.go b/bridge/github/github.go index 19dc8a08..a02d9460 100644 --- a/bridge/github/github.go +++ b/bridge/github/github.go @@ -19,8 +19,8 @@ const ( metaKeyGithubUrl = "github-url" metaKeyGithubLogin = "github-login" - keyOwner = "owner" - keyProject = "project" + confKeyOwner = "owner" + confKeyProject = "project" githubV3Url = "https://api.github.com" defaultTimeout = 60 * time.Second @@ -30,7 +30,7 @@ var _ core.BridgeImpl = &Github{} type Github struct{} -func (*Github) Target() string { +func (Github) Target() string { return target } @@ -38,11 +38,11 @@ func (g *Github) LoginMetaKey() string { return metaKeyGithubLogin } -func (*Github) NewImporter() core.Importer { +func (Github) NewImporter() core.Importer { return &githubImporter{} } -func (*Github) NewExporter() core.Exporter { +func (Github) NewExporter() core.Exporter { return &githubExporter{} } diff --git a/bridge/github/import.go b/bridge/github/import.go index ea0ccba3..3267c013 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -49,7 +49,7 @@ func (gi *githubImporter) Init(repo *cache.RepoCache, conf core.Configuration) e // ImportAll iterate over all the configured repository issues and ensure the creation of the // missing issues / timeline items / edits / label events ... func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { - gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyOwner], gi.conf[keyProject], since) + gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[confKeyOwner], gi.conf[confKeyProject], since) out := make(chan core.ImportResult) gi.out = out diff --git a/bridge/github/import_test.go b/bridge/github/import_test.go index 7eb901d3..4f75f368 100644 --- a/bridge/github/import_test.go +++ b/bridge/github/import_test.go @@ -151,8 +151,8 @@ func Test_Importer(t *testing.T) { importer := &githubImporter{} err = importer.Init(backend, core.Configuration{ - keyOwner: "MichaelMure", - keyProject: "git-bug-test-github-bridge", + confKeyOwner: "MichaelMure", + confKeyProject: "git-bug-test-github-bridge", }) require.NoError(t, err) diff --git a/bridge/gitlab/config.go b/bridge/gitlab/config.go index 9bd9c3c7..94026635 100644 --- a/bridge/gitlab/config.go +++ b/bridge/gitlab/config.go @@ -1,18 +1,13 @@ package gitlab import ( - "bufio" "fmt" "net/url" - "os" "path" "regexp" - "sort" "strconv" "strings" - "time" - text "github.com/MichaelMure/go-term-text" "github.com/pkg/errors" "github.com/xanzy/go-gitlab" @@ -21,22 +16,23 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/colors" ) var ( ErrBadProjectURL = errors.New("bad project url") ) -func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.Project != "" { - fmt.Println("warning: --project is ineffective for a gitlab bridge") - } - if params.Owner != "" { - fmt.Println("warning: --owner is ineffective for a gitlab bridge") +func (g *Gitlab) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "BaseURL": nil, + "Login": nil, + "CredPrefix": nil, + "TokenRaw": nil, } +} - conf := make(core.Configuration) +func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var baseUrl string @@ -44,7 +40,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor case params.BaseURL != "": baseUrl = params.BaseURL default: - baseUrl, err = promptBaseUrlOptions() + baseUrl, err = input.PromptDefault("Gitlab server URL", "URL", defaultBaseURL, input.Required, input.IsURL) if err != nil { return nil, errors.Wrap(err, "base url prompt") } @@ -117,9 +113,10 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor return nil, errors.Wrap(err, "project validation") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyProjectID] = strconv.Itoa(id) - conf[keyGitlabBaseUrl] = baseUrl + conf[confKeyProjectID] = strconv.Itoa(id) + conf[confKeyGitlabBaseUrl] = baseUrl err = g.ValidateConfig(conf) if err != nil { @@ -143,107 +140,35 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error { } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyGitlabBaseUrl]; !ok { - return fmt.Errorf("missing %s key", keyGitlabBaseUrl) + if _, ok := conf[confKeyGitlabBaseUrl]; !ok { + return fmt.Errorf("missing %s key", confKeyGitlabBaseUrl) } - if _, ok := conf[keyProjectID]; !ok { - return fmt.Errorf("missing %s key", keyProjectID) + if _, ok := conf[confKeyProjectID]; !ok { + return fmt.Errorf("missing %s key", confKeyProjectID) } return nil } -func promptBaseUrlOptions() (string, error) { - index, err := input.PromptChoice("Gitlab base url", []string{ - "https://gitlab.com", - "enter your own base url", - }) - +func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), + ) if err != nil { - return "", err - } - - if index == 0 { - return defaultBaseURL, nil - } else { - return promptBaseUrl() - } -} - -func promptBaseUrl() (string, error) { - validator := func(name string, value string) (string, error) { - u, err := url.Parse(value) - if err != nil { - return err.Error(), nil - } - if u.Scheme == "" { - return "missing scheme", nil - } - if u.Host == "" { - return "missing host", nil - } - return "", nil + return nil, err } - return input.Prompt("Base url", "url", input.Required, validator) -} - -func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { - for { - creds, err := auth.List(repo, - auth.WithTarget(target), - auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyLogin, login), - auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), - ) - if err != nil { - return nil, err - } - - // if we don't have existing token, fast-track to the token prompt - if len(creds) == 0 { - return promptToken(baseUrl) - } - - fmt.Println() - fmt.Println("[1]: enter my token") - - fmt.Println() - fmt.Println("Existing tokens for Gitlab:") - - sort.Sort(auth.ById(creds)) - for i, cred := range creds { - token := cred.(*auth.Token) - fmt.Printf("[%d]: %s => %s (%s)\n", - i+2, - colors.Cyan(token.ID().Human()), - colors.Red(text.TruncateMax(token.Value, 10)), - token.CreateTime().Format(time.RFC822), - ) - } - - fmt.Println() - fmt.Print("Select option: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Println() - if err != nil { - return nil, err - } - - line = strings.TrimSpace(line) - index, err := strconv.Atoi(line) - if err != nil || index < 1 || index > len(creds)+1 { - fmt.Println("invalid input") - continue - } - - switch index { - case 1: - return promptToken(baseUrl) - default: - return creds[index-2], nil - } + cred, err := input.PromptCredential(target, "token", creds) + switch err { + case nil: + return cred, nil + case input.ErrDirectPrompt: + return promptToken(baseUrl) + default: + return nil, err } } @@ -285,64 +210,12 @@ func promptToken(baseUrl string) (*auth.Token, error) { } func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) { - // remote suggestions - remotes, err := repo.GetRemotes() + validRemotes, err := getValidGitlabRemoteURLs(repo, baseUrl) if err != nil { - return "", errors.Wrap(err, "getting remotes") - } - - validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes) - if len(validRemotes) > 0 { - for { - fmt.Println("\nDetected projects:") - - // print valid remote gitlab urls - for i, remote := range validRemotes { - fmt.Printf("[%d]: %v\n", i+1, remote) - } - - fmt.Printf("\n[0]: Another project\n\n") - fmt.Printf("Select option: ") - - 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(validRemotes) { - fmt.Println("invalid input") - continue - } - - // if user want to enter another project url break this loop - if index == 0 { - break - } - - return validRemotes[index-1], nil - } + return "", err } - // manually enter gitlab url - for { - fmt.Print("Gitlab project URL: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", err - } - - projectURL := strings.TrimSpace(line) - if projectURL == "" { - fmt.Println("URL is empty") - continue - } - - return projectURL, nil - } + return input.PromptURLWithRemote("Gitlab project URL", "URL", validRemotes, input.Required) } func getProjectPath(baseUrl, projectUrl string) (string, error) { @@ -364,7 +237,12 @@ func getProjectPath(baseUrl, projectUrl string) (string, error) { return objectUrl.Path[1:], nil } -func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string { +func getValidGitlabRemoteURLs(repo repository.RepoCommon, baseUrl string) ([]string, error) { + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + urls := make([]string, 0, len(remotes)) for _, u := range remotes { path, err := getProjectPath(baseUrl, u) @@ -375,7 +253,7 @@ func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []strin urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path)) } - return urls + return urls, nil } func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) { diff --git a/bridge/gitlab/export.go b/bridge/gitlab/export.go index d747c6ac..156aabaa 100644 --- a/bridge/gitlab/export.go +++ b/bridge/gitlab/export.go @@ -44,10 +44,10 @@ func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) e ge.cachedOperationIDs = make(map[string]string) // get repository node id - ge.repositoryID = ge.conf[keyProjectID] + ge.repositoryID = ge.conf[confKeyProjectID] // preload all clients - err := ge.cacheAllClient(repo, ge.conf[keyGitlabBaseUrl]) + err := ge.cacheAllClient(repo, ge.conf[confKeyGitlabBaseUrl]) if err != nil { return err } @@ -81,7 +81,7 @@ func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache, baseURL string) } if _, ok := ge.identityClient[user.Id()]; !ok { - client, err := buildClient(ge.conf[keyGitlabBaseUrl], creds[0].(*auth.Token)) + client, err := buildClient(ge.conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token)) if err != nil { return err } @@ -138,7 +138,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - ge.exportBug(ctx, b, since, out) + ge.exportBug(ctx, b, out) } } } @@ -148,7 +148,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, } // exportBug publish bugs and related events -func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) { +func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) { snapshot := b.Snapshot() var bugUpdated bool @@ -177,7 +177,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc gitlabID, ok := snapshot.GetCreateMetadata(metaKeyGitlabId) if ok { gitlabBaseUrl, ok := snapshot.GetCreateMetadata(metaKeyGitlabBaseUrl) - if ok && gitlabBaseUrl != ge.conf[keyGitlabBaseUrl] { + if ok && gitlabBaseUrl != ge.conf[confKeyGitlabBaseUrl] { out <- core.NewExportNothing(b.Id(), "skipping issue imported from another Gitlab instance") return } @@ -189,7 +189,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc return } - if projectID != ge.conf[keyProjectID] { + if projectID != ge.conf[confKeyProjectID] { out <- core.NewExportNothing(b.Id(), "skipping issue imported from another repository") return } diff --git a/bridge/gitlab/export_test.go b/bridge/gitlab/export_test.go index 768b899c..5fbb392f 100644 --- a/bridge/gitlab/export_test.go +++ b/bridge/gitlab/export_test.go @@ -194,8 +194,8 @@ func TestPushPull(t *testing.T) { // initialize exporter exporter := &gitlabExporter{} err = exporter.Init(backend, core.Configuration{ - keyProjectID: strconv.Itoa(projectID), - keyGitlabBaseUrl: defaultBaseURL, + confKeyProjectID: strconv.Itoa(projectID), + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) @@ -222,8 +222,8 @@ func TestPushPull(t *testing.T) { importer := &gitlabImporter{} err = importer.Init(backend, core.Configuration{ - keyProjectID: strconv.Itoa(projectID), - keyGitlabBaseUrl: defaultBaseURL, + confKeyProjectID: strconv.Itoa(projectID), + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) diff --git a/bridge/gitlab/gitlab.go b/bridge/gitlab/gitlab.go index 8512379c..ec7b7e57 100644 --- a/bridge/gitlab/gitlab.go +++ b/bridge/gitlab/gitlab.go @@ -19,8 +19,8 @@ const ( metaKeyGitlabProject = "gitlab-project-id" metaKeyGitlabBaseUrl = "gitlab-base-url" - keyProjectID = "project-id" - keyGitlabBaseUrl = "base-url" + confKeyProjectID = "project-id" + confKeyGitlabBaseUrl = "base-url" defaultBaseURL = "https://gitlab.com/" defaultTimeout = 60 * time.Second @@ -30,7 +30,7 @@ var _ core.BridgeImpl = &Gitlab{} type Gitlab struct{} -func (*Gitlab) Target() string { +func (Gitlab) Target() string { return target } @@ -38,11 +38,11 @@ func (g *Gitlab) LoginMetaKey() string { return metaKeyGitlabLogin } -func (*Gitlab) NewImporter() core.Importer { +func (Gitlab) NewImporter() core.Importer { return &gitlabImporter{} } -func (*Gitlab) NewExporter() core.Exporter { +func (Gitlab) NewExporter() core.Exporter { return &gitlabExporter{} } diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go index 4fccb47e..c8d74bef 100644 --- a/bridge/gitlab/import.go +++ b/bridge/gitlab/import.go @@ -36,7 +36,7 @@ func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) e creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyBaseURL, conf[keyGitlabBaseUrl]), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyGitlabBaseUrl]), ) if err != nil { return err @@ -46,7 +46,7 @@ func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) e return ErrMissingIdentityToken } - gi.client, err = buildClient(conf[keyGitlabBaseUrl], creds[0].(*auth.Token)) + gi.client, err = buildClient(conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token)) if err != nil { return err } @@ -57,7 +57,7 @@ func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) e // ImportAll iterate over all the configured repository issues (notes) and ensure the creation // of the missing issues / comments / label events / title changes ... func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { - gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyProjectID], since) + gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[confKeyProjectID], since) out := make(chan core.ImportResult) gi.out = out @@ -147,8 +147,8 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue core.MetaKeyOrigin: target, metaKeyGitlabId: parseID(issue.IID), metaKeyGitlabUrl: issue.WebURL, - metaKeyGitlabProject: gi.conf[keyProjectID], - metaKeyGitlabBaseUrl: gi.conf[keyGitlabBaseUrl], + metaKeyGitlabProject: gi.conf[confKeyProjectID], + metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl], }, ) diff --git a/bridge/gitlab/import_test.go b/bridge/gitlab/import_test.go index 99d0d69e..b70b291e 100644 --- a/bridge/gitlab/import_test.go +++ b/bridge/gitlab/import_test.go @@ -106,8 +106,8 @@ func TestImport(t *testing.T) { importer := &gitlabImporter{} err = importer.Init(backend, core.Configuration{ - keyProjectID: projectID, - keyGitlabBaseUrl: defaultBaseURL, + confKeyProjectID: projectID, + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) diff --git a/bridge/launchpad/config.go b/bridge/launchpad/config.go index e029fad3..8567675f 100644 --- a/bridge/launchpad/config.go +++ b/bridge/launchpad/config.go @@ -13,18 +13,14 @@ import ( var ErrBadProjectURL = errors.New("bad Launchpad project URL") -func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.TokenRaw != "" { - fmt.Println("warning: token params are ineffective for a Launchpad bridge") - } - if params.Owner != "" { - fmt.Println("warning: --owner is ineffective for a Launchpad bridge") - } - if params.BaseURL != "" { - fmt.Println("warning: --base-url is ineffective for a Launchpad bridge") +func (Launchpad) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "Project": nil, } +} - conf := make(core.Configuration) +func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var project string @@ -52,8 +48,9 @@ func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) ( return nil, fmt.Errorf("project doesn't exist") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyProject] = project + conf[confKeyProject] = project err = l.ValidateConfig(conf) if err != nil { @@ -70,8 +67,8 @@ func (*Launchpad) ValidateConfig(conf core.Configuration) error { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyProject]; !ok { - return fmt.Errorf("missing %s key", keyProject) + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) } return nil diff --git a/bridge/launchpad/launchpad.go b/bridge/launchpad/launchpad.go index b4fcdd00..51ee79d2 100644 --- a/bridge/launchpad/launchpad.go +++ b/bridge/launchpad/launchpad.go @@ -13,7 +13,7 @@ const ( metaKeyLaunchpadID = "launchpad-id" metaKeyLaunchpadLogin = "launchpad-login" - keyProject = "project" + confKeyProject = "project" defaultTimeout = 60 * time.Second ) @@ -26,7 +26,7 @@ func (*Launchpad) Target() string { return "launchpad-preview" } -func (l *Launchpad) LoginMetaKey() string { +func (Launchpad) LoginMetaKey() string { return metaKeyLaunchpadLogin } diff --git a/commands/bridge_configure.go b/commands/bridge_configure.go index 0e29d06a..89553633 100644 --- a/commands/bridge_configure.go +++ b/commands/bridge_configure.go @@ -153,12 +153,13 @@ func promptName(repo repository.RepoConfig) (string, error) { var bridgeConfigureCmd = &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. - Repository configuration can be made by passing either the --url flag or the --project and --owner flags. If the three flags are provided git-bug will use --project and --owner flags. - Token configuration can be directly passed with the --token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one.`, + 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]: launchpad-preview +[2]: gitlab +[3]: jira +[4]: launchpad-preview + target: 1 name [default]: default @@ -215,12 +216,13 @@ func init() { bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureName, "name", "n", "", "A distinctive name to identify the bridge") bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureTarget, "target", "t", "", fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.URL, "url", "u", "", "The URL of the target repository") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.BaseURL, "base-url", "b", "", "The base URL of your issue tracker service") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Owner, "owner", "o", "", "The owner of the target repository") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for the API (see \"git-bug bridge auth\")") - bridgeConfigureCmd.Flags().StringVar(&bridgeConfigureToken, "token", "", "A raw authentication token for the API") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.URL, "url", "u", "", "The URL of the remote repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.BaseURL, "base-url", "b", "", "The base URL of your remote issue tracker") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Login, "login", "l", "", "The login on your remote issue tracker") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for your remote issue tracker (see \"git-bug bridge auth\")") + bridgeConfigureCmd.Flags().StringVar(&bridgeConfigureToken, "token", "", "A raw authentication token for the remote issue tracker") bridgeConfigureCmd.Flags().BoolVar(&bridgeConfigureTokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Project, "project", "p", "", "The name of the target repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Owner, "owner", "o", "", "The owner of the remote repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Project, "project", "p", "", "The name of the remote repository") bridgeConfigureCmd.Flags().SortFlags = false } diff --git a/doc/man/git-bug-bridge-configure.1 b/doc/man/git-bug-bridge-configure.1 index d1dc9f7d..385d0949 100644 --- a/doc/man/git-bug-bridge-configure.1 +++ b/doc/man/git-bug-bridge-configure.1 @@ -19,8 +19,6 @@ git\-bug\-bridge\-configure \- Configure a new bridge. .nf 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. -Repository configuration can be made by passing either the \-\-url flag or the \-\-project and \-\-owner flags. If the three flags are provided git\-bug will use \-\-project and \-\-owner flags. -Token configuration can be directly passed with the \-\-token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one. .fi .RE @@ -37,31 +35,35 @@ Token configuration can be directly passed with the \-\-token flag or in the ter .PP \fB\-u\fP, \fB\-\-url\fP="" - The URL of the target repository + The URL of the remote repository .PP \fB\-b\fP, \fB\-\-base\-url\fP="" - The base URL of your issue tracker service + The base URL of your remote issue tracker .PP -\fB\-o\fP, \fB\-\-owner\fP="" - The owner of the target repository +\fB\-l\fP, \fB\-\-login\fP="" + The login on your remote issue tracker .PP \fB\-c\fP, \fB\-\-credential\fP="" - The identifier or prefix of an already known credential for the API (see "git\-bug bridge auth") + The identifier or prefix of an already known credential for your remote issue tracker (see "git\-bug bridge auth") .PP \fB\-\-token\fP="" - A raw authentication token for the API + A raw authentication token for the remote issue tracker .PP \fB\-\-token\-stdin\fP[=false] Will read the token from stdin and ignore \-\-token +.PP +\fB\-o\fP, \fB\-\-owner\fP="" + The owner of the remote repository + .PP \fB\-p\fP, \fB\-\-project\fP="" - The name of the target repository + The name of the remote repository .PP \fB\-h\fP, \fB\-\-help\fP[=false] @@ -75,7 +77,10 @@ Token configuration can be directly passed with the \-\-token flag or in the ter .nf # Interactive example [1]: github -[2]: launchpad\-preview +[2]: gitlab +[3]: jira +[4]: launchpad\-preview + target: 1 name [default]: default diff --git a/doc/md/git-bug_bridge_configure.md b/doc/md/git-bug_bridge_configure.md index c0f89cf3..9695684b 100644 --- a/doc/md/git-bug_bridge_configure.md +++ b/doc/md/git-bug_bridge_configure.md @@ -5,8 +5,6 @@ Configure a new bridge. ### Synopsis 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. - Repository configuration can be made by passing either the --url flag or the --project and --owner flags. If the three flags are provided git-bug will use --project and --owner flags. - Token configuration can be directly passed with the --token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one. ``` git-bug bridge configure [flags] @@ -17,7 +15,10 @@ git-bug bridge configure [flags] ``` # Interactive example [1]: github -[2]: launchpad-preview +[2]: gitlab +[3]: jira +[4]: launchpad-preview + target: 1 name [default]: default @@ -72,13 +73,14 @@ git bug bridge configure \ ``` -n, --name string A distinctive name to identify the bridge -t, --target string The target of the bridge. Valid values are [github,gitlab,launchpad-preview] - -u, --url string The URL of the target repository - -b, --base-url string The base URL of your issue tracker service - -o, --owner string The owner of the target repository - -c, --credential string The identifier or prefix of an already known credential for the API (see "git-bug bridge auth") - --token string A raw authentication token for the API + -u, --url string The URL of the remote repository + -b, --base-url string The base URL of your remote issue tracker + -l, --login string The login on your remote issue tracker + -c, --credential string The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth") + --token string A raw authentication token for the remote issue tracker --token-stdin Will read the token from stdin and ignore --token - -p, --project string The name of the target repository + -o, --owner string The owner of the remote repository + -p, --project string The name of the remote repository -h, --help help for configure ``` diff --git a/input/prompt.go b/input/prompt.go index 960ecd62..2093695f 100644 --- a/input/prompt.go +++ b/input/prompt.go @@ -2,14 +2,20 @@ package input import ( "bufio" + "errors" "fmt" + "net/url" "os" + "sort" "strconv" "strings" "syscall" + "time" "golang.org/x/crypto/ssh/terminal" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/util/colors" "github.com/MichaelMure/git-bug/util/interrupt" ) @@ -26,11 +32,27 @@ func Required(name string, value string) (string, error) { return "", nil } +// IsURL is a validator checking that the value is a fully formed URL +func IsURL(name string, value string) (string, error) { + u, err := url.Parse(value) + if err != nil { + return fmt.Sprintf("%s is invalid: %v", name, err), nil + } + if u.Scheme == "" { + return fmt.Sprintf("%s is missing a scheme", name), nil + } + if u.Host == "" { + return fmt.Sprintf("%s is missing a host", name), nil + } + return "", nil +} + func Prompt(prompt, name string, validators ...PromptValidator) (string, error) { return PromptDefault(prompt, name, "", validators...) } func PromptDefault(prompt, name, preValue string, validators ...PromptValidator) (string, error) { +loop: for { if preValue != "" { _, _ = fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, preValue) @@ -56,7 +78,7 @@ func PromptDefault(prompt, name, preValue string, validators ...PromptValidator) } if complaint != "" { _, _ = fmt.Fprintln(os.Stderr, complaint) - continue + continue loop } } @@ -75,6 +97,7 @@ func PromptPassword(prompt, name string, validators ...PromptValidator) (string, }) defer cancel() +loop: for { _, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt) @@ -96,7 +119,7 @@ func PromptPassword(prompt, name string, validators ...PromptValidator) (string, } if complaint != "" { _, _ = fmt.Fprintln(os.Stderr, complaint) - continue + continue loop } } @@ -121,10 +144,163 @@ func PromptChoice(prompt string, choices []string) (int, error) { index, err := strconv.Atoi(line) if err != nil || index < 1 || index > len(choices) { - fmt.Println("invalid input") + _, _ = fmt.Fprintf(os.Stderr, "invalid input") continue } return index, nil } } + +func PromptURLWithRemote(prompt, name string, validRemotes []string, validators ...PromptValidator) (string, error) { + if len(validRemotes) == 0 { + return Prompt(prompt, name, validators...) + } + + sort.Strings(validRemotes) + + for { + _, _ = fmt.Fprintln(os.Stderr, "\nDetected projects:") + + for i, remote := range validRemotes { + _, _ = fmt.Fprintf(os.Stderr, "[%d]: %v\n", i+1, remote) + } + + _, _ = fmt.Fprintf(os.Stderr, "\n[0]: Another project\n\n") + _, _ = fmt.Fprintf(os.Stderr, "Select option: ") + + 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(validRemotes) { + _, _ = fmt.Fprintf(os.Stderr, "invalid input") + continue + } + + // if user want to enter another project url break this loop + if index == 0 { + break + } + + return validRemotes[index-1], nil + } + + return Prompt(prompt, name, validators...) +} + +var ErrDirectPrompt = errors.New("direct prompt selected") +var ErrInteractiveCreation = errors.New("interactive creation selected") + +func PromptCredential(target, name string, credentials []auth.Credential) (auth.Credential, error) { + if len(credentials) == 0 { + return nil, nil + } + + sort.Sort(auth.ById(credentials)) + + for { + _, _ = fmt.Fprintf(os.Stderr, "[1]: enter my %s\n", name) + + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Existing %s for %s:", name, target) + + for i, cred := range credentials { + 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, ",") + + fmt.Printf("[%d]: %s => (%s) (%s)\n", + i+2, + colors.Cyan(cred.ID().Human()), + metaFmt, + cred.CreateTime().Format(time.RFC822), + ) + } + + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + _, _ = fmt.Fprintln(os.Stderr) + if err != nil { + return nil, err + } + + line = strings.TrimSpace(line) + index, err := strconv.Atoi(line) + if err != nil || index < 1 || index > len(credentials)+1 { + _, _ = fmt.Fprintln(os.Stderr, "invalid input") + continue + } + + switch index { + case 1: + return nil, ErrDirectPrompt + default: + return credentials[index-2], nil + } + } +} + +func PromptCredentialWithInteractive(target, name string, credentials []auth.Credential) (auth.Credential, error) { + sort.Sort(auth.ById(credentials)) + + for { + _, _ = fmt.Fprintf(os.Stderr, "[1]: enter my %s\n", name) + _, _ = fmt.Fprintf(os.Stderr, "[2]: interactive %s creation\n", name) + + if len(credentials) > 0 { + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Existing %s for %s:", name, target) + + for i, cred := range credentials { + 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, ",") + + fmt.Printf("[%d]: %s => (%s) (%s)\n", + i+2, + colors.Cyan(cred.ID().Human()), + metaFmt, + cred.CreateTime().Format(time.RFC822), + ) + } + } + + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + _, _ = fmt.Fprintln(os.Stderr) + if err != nil { + return nil, err + } + + line = strings.TrimSpace(line) + index, err := strconv.Atoi(line) + if err != nil || index < 1 || index > len(credentials)+1 { + _, _ = fmt.Fprintln(os.Stderr, "invalid input") + continue + } + + switch index { + case 1: + return nil, ErrDirectPrompt + case 2: + return nil, ErrInteractiveCreation + default: + return credentials[index-3], nil + } + } +} diff --git a/misc/bash_completion/git-bug b/misc/bash_completion/git-bug index a062bfe8..ef6847c8 100644 --- a/misc/bash_completion/git-bug +++ b/misc/bash_completion/git-bug @@ -412,10 +412,10 @@ _git-bug_bridge_configure() two_word_flags+=("--base-url") two_word_flags+=("-b") local_nonpersistent_flags+=("--base-url=") - flags+=("--owner=") - two_word_flags+=("--owner") - two_word_flags+=("-o") - local_nonpersistent_flags+=("--owner=") + flags+=("--login=") + two_word_flags+=("--login") + two_word_flags+=("-l") + local_nonpersistent_flags+=("--login=") flags+=("--credential=") two_word_flags+=("--credential") two_word_flags+=("-c") @@ -425,6 +425,10 @@ _git-bug_bridge_configure() local_nonpersistent_flags+=("--token=") flags+=("--token-stdin") local_nonpersistent_flags+=("--token-stdin") + flags+=("--owner=") + two_word_flags+=("--owner") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--owner=") flags+=("--project=") two_word_flags+=("--project") two_word_flags+=("-p") diff --git a/misc/powershell_completion/git-bug b/misc/powershell_completion/git-bug index b15e6398..54f7b7d0 100644 --- a/misc/powershell_completion/git-bug +++ b/misc/powershell_completion/git-bug @@ -81,18 +81,20 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock { [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge') [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,launchpad-preview]') [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,launchpad-preview]') - [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the target repository') - [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the target repository') - [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your issue tracker service') - [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your issue tracker service') - [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the target repository') - [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the target repository') - [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")') - [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")') - [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the API') + [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the remote repository') + [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the remote repository') + [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker') + [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker') + [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker') + [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker') + [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")') + [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")') + [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the remote issue tracker') [CompletionResult]::new('--token-stdin', 'token-stdin', [CompletionResultType]::ParameterName, 'Will read the token from stdin and ignore --token') - [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the target repository') - [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the target repository') + [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the remote repository') + [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the remote repository') + [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the remote repository') + [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the remote repository') break } 'git-bug;bridge;pull' { diff --git a/misc/zsh_completion/git-bug b/misc/zsh_completion/git-bug index f6d50e08..edfff683 100644 --- a/misc/zsh_completion/git-bug +++ b/misc/zsh_completion/git-bug @@ -194,13 +194,14 @@ function _git-bug_bridge_configure { _arguments \ '(-n --name)'{-n,--name}'[A distinctive name to identify the bridge]:' \ '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,launchpad-preview]]:' \ - '(-u --url)'{-u,--url}'[The URL of the target repository]:' \ - '(-b --base-url)'{-b,--base-url}'[The base URL of your issue tracker service]:' \ - '(-o --owner)'{-o,--owner}'[The owner of the target repository]:' \ - '(-c --credential)'{-c,--credential}'[The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")]:' \ - '--token[A raw authentication token for the API]:' \ + '(-u --url)'{-u,--url}'[The URL of the remote repository]:' \ + '(-b --base-url)'{-b,--base-url}'[The base URL of your remote issue tracker]:' \ + '(-l --login)'{-l,--login}'[The login on your remote issue tracker]:' \ + '(-c --credential)'{-c,--credential}'[The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")]:' \ + '--token[A raw authentication token for the remote issue tracker]:' \ '--token-stdin[Will read the token from stdin and ignore --token]' \ - '(-p --project)'{-p,--project}'[The name of the target repository]:' + '(-o --owner)'{-o,--owner}'[The owner of the remote repository]:' \ + '(-p --project)'{-p,--project}'[The name of the remote repository]:' } function _git-bug_bridge_pull { -- cgit