bridges: massive refactor

Michael Muré created

- automatic flag validation and warning
- generalized prompt
- cleanups

Change summary

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(-)

Detailed changes

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
 	}
 }
 

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)

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)

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")
+	}
+}

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) {

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 {

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)
 

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{}
 }
 

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
 

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)
 

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) {

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
 		}

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)
 

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{}
 }
 

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],
 		},
 	)
 

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)
 

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

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
 }
 

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
 }

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
 

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
 ```
 

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
+		}
+	}
+}

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")

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' {

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 {