Merge pull request #294 from MichaelMure/cred-metadata

Michael MurΓ© created

Cred metadata

Change summary

bridge/bridges.go                       |   7 
bridge/core/auth/credential.go          |  66 +++----
bridge/core/auth/credential_test.go     |  41 ++--
bridge/core/auth/options.go             |  32 +--
bridge/core/auth/token.go               |  31 ++-
bridge/core/bridge.go                   |  30 ++
bridge/core/config.go                   |  46 +++++
bridge/core/interfaces.go               |   5 
bridge/github/config.go                 | 226 +++++---------------------
bridge/github/config_test.go            |   5 
bridge/github/export.go                 |  29 ++
bridge/github/export_test.go            |  15 +
bridge/github/github.go                 |  21 ++
bridge/github/import.go                 |  29 --
bridge/github/import_test.go            |  11 
bridge/gitlab/config.go                 | 164 ++++++++----------
bridge/gitlab/export.go                 |  24 ++
bridge/gitlab/export_test.go            |  13 +
bridge/gitlab/gitlab.go                 |   6 
bridge/gitlab/import.go                 |  17 -
bridge/gitlab/import_test.go            |  11 
bridge/launchpad/config.go              |  33 ---
bridge/launchpad/import.go              |   6 
bridge/launchpad/launchpad.go           |  19 ++
bug/status.go                           |   2 
cache/bug_excerpt.go                    |  18 -
cache/filter.go                         |   3 
cache/identity_cache.go                 |   4 
cache/identity_excerpt.go               |  17 -
cache/query.go                          |   4 
cache/repo_cache.go                     |  91 +++++-----
commands/bridge_auth.go                 |  26 --
commands/bridge_auth_addtoken.go        |  51 +++++
commands/bridge_auth_show.go            |  32 +--
commands/user.go                        |   3 
commands/user_adopt.go                  |  15 -
commands/user_create.go                 |   8 
doc/man/git-bug-bridge-auth-add-token.1 |   8 
doc/md/git-bug_bridge_auth_add-token.md |   2 
go.mod                                  |   2 
go.sum                                  |   2 
graphql/graph/gen_graph.go              |  47 -----
graphql/resolvers/identity.go           |   3 
graphql/schema/identity.graphql         |   4 
graphql/schema/root.graphql             |   2 
identity/bare.go                        |  45 +----
identity/bare_test.go                   |   1 
identity/common.go                      |   2 
identity/identity.go                    |  67 +++++--
identity/identity_actions_test.go       |   8 
identity/identity_stub.go               |   4 
identity/identity_test.go               |  45 ++--
identity/interface.go                   |   7 
identity/key.go                         |   5 
identity/version.go                     |  40 ++--
identity/version_test.go                |   3 
input/prompt.go                         | 108 +++++++++++-
misc/bash_completion/git-bug            |   8 
misc/powershell_completion/git-bug      |   4 
misc/zsh_completion/git-bug             |   4 
60 files changed, 786 insertions(+), 796 deletions(-)

Detailed changes

bridge/bridges.go πŸ”—

@@ -21,6 +21,13 @@ func Targets() []string {
 	return core.Targets()
 }
 
+// LoginMetaKey return the metadata key used to store the remote bug-tracker login
+// on the user identity. The corresponding value is used to match identities and
+// credentials.
+func LoginMetaKey(target string) (string, error) {
+	return core.LoginMetaKey(target)
+}
+
 // Instantiate a new Bridge for a repo, from the given target and name
 func NewBridge(repo *cache.RepoCache, target string, name string) (*core.Bridge, error) {
 	return core.NewBridge(repo, target, name)

bridge/core/auth/credential.go πŸ”—

@@ -14,9 +14,11 @@ import (
 const (
 	configKeyPrefix     = "git-bug.auth"
 	configKeyKind       = "kind"
-	configKeyUserId     = "userid"
 	configKeyTarget     = "target"
 	configKeyCreateTime = "createtime"
+	configKeyPrefixMeta = "meta."
+
+	MetaKeyLogin = "login"
 )
 
 type CredentialKind string
@@ -32,22 +34,19 @@ func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatc
 	return entity.NewErrMultipleMatch("credential", matching)
 }
 
-// Special Id to mark a credential as being associated to the default user, whoever it might be.
-// The intended use is for the bridge configuration, to be able to create and store a credential
-// with no identities created yet, and then select one with `git-bug user adopt`
-const DefaultUserId = entity.Id("default-user")
-
 type Credential interface {
 	ID() entity.Id
-	UserId() entity.Id
-	updateUserId(id entity.Id)
 	Target() string
 	Kind() CredentialKind
 	CreateTime() time.Time
 	Validate() error
 
+	Metadata() map[string]string
+	GetMetadata(key string) (string, bool)
+	SetMetadata(key string, value string)
+
 	// Return all the specific properties of the credential that need to be saved into the configuration.
-	// This does not include Target, User, Kind and CreateTime.
+	// This does not include Target, Kind, CreateTime and Metadata.
 	toConfig() map[string]string
 }
 
@@ -120,6 +119,20 @@ func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, err
 	return cred, nil
 }
 
+func metaFromConfig(configs map[string]string) map[string]string {
+	result := make(map[string]string)
+	for key, val := range configs {
+		if strings.HasPrefix(key, configKeyPrefixMeta) {
+			key = strings.TrimPrefix(key, configKeyPrefixMeta)
+			result[key] = val
+		}
+	}
+	if len(result) == 0 {
+		return nil
+	}
+	return result
+}
+
 // List load all existing credentials
 func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) {
 	rawConfigs, err := repo.GlobalConfig().ReadAll(configKeyPrefix + ".")
@@ -127,7 +140,7 @@ func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) {
 		return nil, err
 	}
 
-	re, err := regexp.Compile(configKeyPrefix + `.([^.]+).([^.]+)`)
+	re, err := regexp.Compile(`^` + configKeyPrefix + `\.([^.]+)\.([^.]+(?:\.[^.]+)*)$`)
 	if err != nil {
 		panic(err)
 	}
@@ -185,12 +198,6 @@ func Store(repo repository.RepoConfig, cred Credential) error {
 		return err
 	}
 
-	// UserId
-	err = repo.GlobalConfig().StoreString(prefix+configKeyUserId, cred.UserId().String())
-	if err != nil {
-		return err
-	}
-
 	// Target
 	err = repo.GlobalConfig().StoreString(prefix+configKeyTarget, cred.Target())
 	if err != nil {
@@ -203,6 +210,14 @@ func Store(repo repository.RepoConfig, cred Credential) error {
 		return err
 	}
 
+	// Metadata
+	for key, val := range cred.Metadata() {
+		err := repo.GlobalConfig().StoreString(prefix+configKeyPrefixMeta+key, val)
+		if err != nil {
+			return err
+		}
+	}
+
 	// Custom
 	for key, val := range confs {
 		err := repo.GlobalConfig().StoreString(prefix+key, val)
@@ -220,25 +235,6 @@ func Remove(repo repository.RepoConfig, id entity.Id) error {
 	return repo.GlobalConfig().RemoveAll(keyPrefix)
 }
 
-// ReplaceDefaultUser update all the credential attributed to the temporary "default user"
-// with a real user Id
-func ReplaceDefaultUser(repo repository.RepoConfig, id entity.Id) error {
-	list, err := List(repo, WithUserId(DefaultUserId))
-	if err != nil {
-		return err
-	}
-
-	for _, cred := range list {
-		cred.updateUserId(id)
-		err = Store(repo, cred)
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
-}
-
 /*
  * Sorting
  */

bridge/core/auth/credential_test.go πŸ”—

@@ -7,32 +7,23 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/repository"
 )
 
 func TestCredential(t *testing.T) {
 	repo := repository.NewMockRepoForTest()
 
-	user1 := identity.NewIdentity("user1", "email")
-	err := user1.Commit(repo)
-	assert.NoError(t, err)
-
-	user2 := identity.NewIdentity("user2", "email")
-	err = user2.Commit(repo)
-	assert.NoError(t, err)
-
-	storeToken := func(user identity.Interface, val string, target string) *Token {
-		token := NewToken(user.Id(), val, target)
-		err = Store(repo, token)
+	storeToken := func(val string, target string) *Token {
+		token := NewToken(val, target)
+		err := Store(repo, token)
 		require.NoError(t, err)
 		return token
 	}
 
-	token := storeToken(user1, "foobar", "github")
+	token := storeToken("foobar", "github")
 
 	// Store + Load
-	err = Store(repo, token)
+	err := Store(repo, token)
 	assert.NoError(t, err)
 
 	token2, err := LoadWithId(repo, token.ID())
@@ -50,8 +41,8 @@ func TestCredential(t *testing.T) {
 	token.createTime = token3.CreateTime()
 	assert.Equal(t, token, token3)
 
-	token4 := storeToken(user1, "foo", "gitlab")
-	token5 := storeToken(user2, "bar", "github")
+	token4 := storeToken("foo", "gitlab")
+	token5 := storeToken("bar", "github")
 
 	// List + options
 	creds, err := List(repo, WithTarget("github"))
@@ -62,14 +53,6 @@ func TestCredential(t *testing.T) {
 	assert.NoError(t, err)
 	sameIds(t, creds, []Credential{token4})
 
-	creds, err = List(repo, WithUser(user1))
-	assert.NoError(t, err)
-	sameIds(t, creds, []Credential{token, token4})
-
-	creds, err = List(repo, WithUserId(user1.Id()))
-	assert.NoError(t, err)
-	sameIds(t, creds, []Credential{token, token4})
-
 	creds, err = List(repo, WithKind(KindToken))
 	assert.NoError(t, err)
 	sameIds(t, creds, []Credential{token, token4, token5})
@@ -78,6 +61,16 @@ func TestCredential(t *testing.T) {
 	assert.NoError(t, err)
 	sameIds(t, creds, []Credential{})
 
+	// Metadata
+
+	token4.SetMetadata("key", "value")
+	err = Store(repo, token4)
+	assert.NoError(t, err)
+
+	creds, err = List(repo, WithMeta("key", "value"))
+	assert.NoError(t, err)
+	sameIds(t, creds, []Credential{token4})
+
 	// Exist
 	exist := IdExist(repo, token.ID())
 	assert.True(t, exist)

bridge/core/auth/options.go πŸ”—

@@ -1,14 +1,9 @@
 package auth
 
-import (
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
-)
-
 type options struct {
 	target string
-	userId entity.Id
 	kind   CredentialKind
+	meta   map[string]string
 }
 
 type Option func(opts *options)
@@ -26,12 +21,14 @@ func (opts *options) Match(cred Credential) bool {
 		return false
 	}
 
-	if opts.userId != "" && cred.UserId() != opts.userId {
+	if opts.kind != "" && cred.Kind() != opts.kind {
 		return false
 	}
 
-	if opts.kind != "" && cred.Kind() != opts.kind {
-		return false
+	for key, val := range opts.meta {
+		if v, ok := cred.GetMetadata(key); !ok || v != val {
+			return false
+		}
 	}
 
 	return true
@@ -43,20 +40,17 @@ func WithTarget(target string) Option {
 	}
 }
 
-func WithUser(user identity.Interface) Option {
-	return func(opts *options) {
-		opts.userId = user.Id()
-	}
-}
-
-func WithUserId(userId entity.Id) Option {
+func WithKind(kind CredentialKind) Option {
 	return func(opts *options) {
-		opts.userId = userId
+		opts.kind = kind
 	}
 }
 
-func WithKind(kind CredentialKind) Option {
+func WithMeta(key string, val string) Option {
 	return func(opts *options) {
-		opts.kind = kind
+		if opts.meta == nil {
+			opts.meta = make(map[string]string)
+		}
+		opts.meta[key] = val
 	}
 }

bridge/core/auth/token.go πŸ”—

@@ -18,16 +18,15 @@ var _ Credential = &Token{}
 
 // Token holds an API access token data
 type Token struct {
-	userId     entity.Id
 	target     string
 	createTime time.Time
 	Value      string
+	meta       map[string]string
 }
 
 // NewToken instantiate a new token
-func NewToken(userId entity.Id, value, target string) *Token {
+func NewToken(value, target string) *Token {
 	return &Token{
-		userId:     userId,
 		target:     target,
 		createTime: time.Now(),
 		Value:      value,
@@ -37,7 +36,6 @@ func NewToken(userId entity.Id, value, target string) *Token {
 func NewTokenFromConfig(conf map[string]string) *Token {
 	token := &Token{}
 
-	token.userId = entity.Id(conf[configKeyUserId])
 	token.target = conf[configKeyTarget]
 	if createTime, ok := conf[configKeyCreateTime]; ok {
 		if t, err := repository.ParseTimestamp(createTime); err == nil {
@@ -46,6 +44,7 @@ func NewTokenFromConfig(conf map[string]string) *Token {
 	}
 
 	token.Value = conf[tokenValueKey]
+	token.meta = metaFromConfig(conf)
 
 	return token
 }
@@ -55,14 +54,6 @@ func (t *Token) ID() entity.Id {
 	return entity.Id(fmt.Sprintf("%x", sum))
 }
 
-func (t *Token) UserId() entity.Id {
-	return t.userId
-}
-
-func (t *Token) updateUserId(id entity.Id) {
-	t.userId = id
-}
-
 func (t *Token) Target() string {
 	return t.target
 }
@@ -92,6 +83,22 @@ func (t *Token) Validate() error {
 	return nil
 }
 
+func (t *Token) Metadata() map[string]string {
+	return t.meta
+}
+
+func (t *Token) GetMetadata(key string) (string, bool) {
+	val, ok := t.meta[key]
+	return val, ok
+}
+
+func (t *Token) SetMetadata(key string, value string) {
+	if t.meta == nil {
+		t.meta = make(map[string]string)
+	}
+	t.meta[key] = value
+}
+
 func (t *Token) toConfig() map[string]string {
 	return map[string]string{
 		tokenValueKey: t.Value,

bridge/core/bridge.go πŸ”—

@@ -28,16 +28,18 @@ 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
-	Project    string
-	URL        string
-	BaseURL    string
-	CredPrefix string
-	TokenRaw   string
+	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
@@ -58,7 +60,11 @@ func Register(impl BridgeImpl) {
 	if bridgeImpl == nil {
 		bridgeImpl = make(map[string]reflect.Type)
 	}
+	if bridgeLoginMetaKey == nil {
+		bridgeLoginMetaKey = make(map[string]string)
+	}
 	bridgeImpl[impl.Target()] = reflect.TypeOf(impl)
+	bridgeLoginMetaKey[impl.Target()] = impl.LoginMetaKey()
 }
 
 // Targets return all known bridge implementation target
@@ -80,6 +86,18 @@ func TargetExist(target string) bool {
 	return ok
 }
 
+// LoginMetaKey return the metadata key used to store the remote bug-tracker login
+// on the user identity. The corresponding value is used to match identities and
+// credentials.
+func LoginMetaKey(target string) (string, error) {
+	metaKey, ok := bridgeLoginMetaKey[target]
+	if !ok {
+		return "", fmt.Errorf("unknown bridge target %v", target)
+	}
+
+	return metaKey, nil
+}
+
 // Instantiate a new Bridge for a repo, from the given target and name
 func NewBridge(repo *cache.RepoCache, target string, name string) (*Bridge, error) {
 	implType, ok := bridgeImpl[target]

bridge/core/config.go πŸ”—

@@ -0,0 +1,46 @@
+package core
+
+import (
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/identity"
+)
+
+func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error {
+	// if no user exist with the given login metadata
+	_, err := repo.ResolveIdentityImmutableMetadata(metaKey, login)
+	if err != nil && err != identity.ErrIdentityNotExist {
+		// real error
+		return err
+	}
+	if err == nil {
+		// found an already valid user, all good
+		return nil
+	}
+
+	// if a default user exist, tag it with the login
+	user, err := repo.GetUserIdentity()
+	if err != nil && err != identity.ErrNoIdentitySet {
+		// real error
+		return err
+	}
+	if err == nil {
+		// found one
+		user.SetMetadata(metaKey, login)
+		return user.CommitAsNeeded()
+	}
+
+	// otherwise create a user with that metadata
+	i, err := repo.NewIdentityFromGitUserRaw(map[string]string{
+		metaKey: login,
+	})
+	if err != nil {
+		return err
+	}
+
+	err = repo.SetUserIdentity(i)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

bridge/core/interfaces.go πŸ”—

@@ -13,6 +13,11 @@ type BridgeImpl interface {
 	// Target return the target of the bridge (e.g.: "github")
 	Target() string
 
+	// LoginMetaKey return the metadata key used to store the remote bug-tracker login
+	// on the user identity. The corresponding value is used to match identities and
+	// credentials.
+	LoginMetaKey() string
+
 	// Configure handle the user interaction and return a key/value configuration
 	// for future use
 	Configure(repo *cache.RepoCache, params BridgeParams) (Configuration, error)

bridge/github/config.go πŸ”—

@@ -14,30 +14,17 @@ import (
 	"sort"
 	"strconv"
 	"strings"
-	"syscall"
 	"time"
 
 	text "github.com/MichaelMure/go-term-text"
 	"github.com/pkg/errors"
-	"golang.org/x/crypto/ssh/terminal"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/input"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/colors"
-	"github.com/MichaelMure/git-bug/util/interrupt"
-)
-
-const (
-	target      = "github"
-	githubV3Url = "https://api.github.com"
-	keyOwner    = "owner"
-	keyProject  = "project"
-
-	defaultTimeout = 60 * time.Second
 )
 
 var (
@@ -51,12 +38,6 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 
 	conf := make(core.Configuration)
 	var err error
-
-	if (params.CredPrefix != "" || params.TokenRaw != "") &&
-		(params.URL == "" && (params.Project == "" || params.Owner == "")) {
-		return nil, fmt.Errorf("you must provide a project URL or Owner/Name to configure this bridge with a token")
-	}
-
 	var owner string
 	var project string
 
@@ -89,15 +70,23 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
 	}
 
-	user, err := repo.GetUserIdentity()
-	if err != nil && err != identity.ErrNoIdentitySet {
-		return nil, err
-	}
+	login := params.Login
+	if login == "" {
+		validator := func(name string, value string) (string, error) {
+			ok, err := validateUsername(value)
+			if err != nil {
+				return "", err
+			}
+			if !ok {
+				return "invalid login", nil
+			}
+			return "", nil
+		}
 
-	// default to a "to be filled" user Id if we don't have a valid one yet
-	userId := auth.DefaultUserId
-	if user != nil {
-		userId = user.Id()
+		login, err = input.Prompt("Github login", "login", input.Required, validator)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	var cred auth.Credential
@@ -108,13 +97,11 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 		if err != nil {
 			return nil, err
 		}
-		if user != nil && cred.UserId() != user.Id() {
-			return nil, fmt.Errorf("selected credential don't match the user")
-		}
 	case params.TokenRaw != "":
-		cred = auth.NewToken(userId, params.TokenRaw, target)
+		cred = auth.NewToken(params.TokenRaw, target)
+		cred.SetMetadata(auth.MetaKeyLogin, login)
 	default:
-		cred, err = promptTokenOptions(repo, userId, owner, project)
+		cred, err = promptTokenOptions(repo, login, owner, project)
 		if err != nil {
 			return nil, err
 		}
@@ -151,7 +138,7 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 		}
 	}
 
-	return conf, nil
+	return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
 }
 
 func (*Github) ValidateConfig(conf core.Configuration) error {
@@ -172,11 +159,11 @@ func (*Github) ValidateConfig(conf core.Configuration) error {
 	return nil
 }
 
-func requestToken(note, username, password string, scope string) (*http.Response, error) {
-	return requestTokenWith2FA(note, username, password, "", scope)
+func requestToken(note, login, password string, scope string) (*http.Response, error) {
+	return requestTokenWith2FA(note, login, password, "", scope)
 }
 
-func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) {
+func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) {
 	url := fmt.Sprintf("%s/authorizations", githubV3Url)
 	params := struct {
 		Scopes      []string `json:"scopes"`
@@ -198,7 +185,7 @@ func requestTokenWith2FA(note, username, password, otpCode string, scope string)
 		return nil, err
 	}
 
-	req.SetBasicAuth(username, password)
+	req.SetBasicAuth(login, password)
 	req.Header.Set("Content-Type", "application/json")
 
 	if otpCode != "" {
@@ -242,9 +229,9 @@ func randomFingerprint() string {
 	return string(b)
 }
 
-func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, owner, project string) (auth.Credential, error) {
+func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
 	for {
-		creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target))
+		creds, err := auth.List(repo, auth.WithTarget(target), auth.WithMeta(auth.MetaKeyLogin, login))
 		if err != nil {
 			return nil, err
 		}
@@ -260,10 +247,11 @@ func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, owner, pro
 			fmt.Println("Existing tokens for Github:")
 			for i, cred := range creds {
 				token := cred.(*auth.Token)
-				fmt.Printf("[%d]: %s => %s (%s)\n",
+				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),
 				)
 			}
@@ -291,13 +279,17 @@ func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, owner, pro
 			if err != nil {
 				return nil, err
 			}
-			return auth.NewToken(userId, value, target), nil
+			token := auth.NewToken(value, target)
+			token.SetMetadata(auth.MetaKeyLogin, login)
+			return token, nil
 		case 2:
-			value, err := loginAndRequestToken(owner, project)
+			value, err := loginAndRequestToken(login, owner, project)
 			if err != nil {
 				return nil, err
 			}
-			return auth.NewToken(userId, value, target), nil
+			token := auth.NewToken(value, target)
+			token.SetMetadata(auth.MetaKeyLogin, login)
+			return token, nil
 		default:
 			return creds[index-3], nil
 		}
@@ -315,29 +307,22 @@ func promptToken() (string, error) {
 	fmt.Println("  - 'repo'       : to be able to read private repositories")
 	fmt.Println()
 
-	re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
+	re, err := regexp.Compile(`^[a-zA-Z0-9]{40}$`)
 	if err != nil {
 		panic("regexp compile:" + err.Error())
 	}
 
-	for {
-		fmt.Print("Enter token: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		if err != nil {
-			return "", err
-		}
-
-		token := strings.TrimSpace(line)
-		if re.MatchString(token) {
-			return token, nil
+	validator := func(name string, value string) (complaint string, err error) {
+		if re.MatchString(value) {
+			return "", nil
 		}
-
-		fmt.Println("token has incorrect format")
+		return "token has incorrect format", nil
 	}
+
+	return input.Prompt("Enter token", "token", input.Required, validator)
 }
 
-func loginAndRequestToken(owner, project string) (string, error) {
+func loginAndRequestToken(login, owner, project string) (string, error) {
 	fmt.Println("git-bug will now generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the global git config.")
 	fmt.Println()
 	fmt.Println("The access scope depend on the type of repository.")
@@ -348,17 +333,13 @@ func loginAndRequestToken(owner, project string) (string, error) {
 	fmt.Println()
 
 	// prompt project visibility to know the token scope needed for the repository
-	isPublic, err := promptProjectVisibility()
+	i, err := input.PromptChoice("repository visibility", []string{"public", "private"})
 	if err != nil {
 		return "", err
 	}
+	isPublic := i == 0
 
-	username, err := promptUsername()
-	if err != nil {
-		return "", err
-	}
-
-	password, err := promptPassword()
+	password, err := input.PromptPassword("Password", "password", input.Required)
 	if err != nil {
 		return "", err
 	}
@@ -377,7 +358,7 @@ func loginAndRequestToken(owner, project string) (string, error) {
 
 	note := fmt.Sprintf("git-bug - %s/%s", owner, project)
 
-	resp, err := requestToken(note, username, password, scope)
+	resp, err := requestToken(note, login, password, scope)
 	if err != nil {
 		return "", err
 	}
@@ -387,12 +368,12 @@ func loginAndRequestToken(owner, project string) (string, error) {
 	// Handle 2FA is needed
 	OTPHeader := resp.Header.Get("X-GitHub-OTP")
 	if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
-		otpCode, err := prompt2FA()
+		otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
 		if err != nil {
 			return "", err
 		}
 
-		resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
+		resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
 		if err != nil {
 			return "", err
 		}
@@ -408,29 +389,6 @@ func loginAndRequestToken(owner, project string) (string, error) {
 	return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
 }
 
-func promptUsername() (string, error) {
-	for {
-		fmt.Print("username: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		if err != nil {
-			return "", err
-		}
-
-		line = strings.TrimSpace(line)
-
-		ok, err := validateUsername(line)
-		if err != nil {
-			return "", err
-		}
-		if ok {
-			return line, nil
-		}
-
-		fmt.Println("invalid username")
-	}
-}
-
 func promptURL(repo repository.RepoCommon) (string, string, error) {
 	// remote suggestions
 	remotes, err := repo.GetRemotes()
@@ -585,87 +543,3 @@ func validateProject(owner, project string, token *auth.Token) (bool, error) {
 
 	return resp.StatusCode == http.StatusOK, nil
 }
-
-func promptPassword() (string, error) {
-	termState, err := terminal.GetState(int(syscall.Stdin))
-	if err != nil {
-		return "", err
-	}
-
-	cancel := interrupt.RegisterCleaner(func() error {
-		return terminal.Restore(int(syscall.Stdin), termState)
-	})
-	defer cancel()
-
-	for {
-		fmt.Print("password: ")
-
-		bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
-		// new line for coherent formatting, ReadPassword clip the normal new line
-		// entered by the user
-		fmt.Println()
-
-		if err != nil {
-			return "", err
-		}
-
-		if len(bytePassword) > 0 {
-			return string(bytePassword), nil
-		}
-
-		fmt.Println("password is empty")
-	}
-}
-
-func prompt2FA() (string, error) {
-	termState, err := terminal.GetState(int(syscall.Stdin))
-	if err != nil {
-		return "", err
-	}
-
-	cancel := interrupt.RegisterCleaner(func() error {
-		return terminal.Restore(int(syscall.Stdin), termState)
-	})
-	defer cancel()
-
-	for {
-		fmt.Print("two-factor authentication code: ")
-
-		byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
-		fmt.Println()
-		if err != nil {
-			return "", err
-		}
-
-		if len(byte2fa) > 0 {
-			return string(byte2fa), nil
-		}
-
-		fmt.Println("code is empty")
-	}
-}
-
-func promptProjectVisibility() (bool, error) {
-	for {
-		fmt.Println("[1]: public")
-		fmt.Println("[2]: private")
-		fmt.Print("repository visibility: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		fmt.Println()
-		if err != nil {
-			return false, err
-		}
-
-		line = strings.TrimSpace(line)
-
-		index, err := strconv.Atoi(line)
-		if err != nil || (index != 1 && index != 2) {
-			fmt.Println("invalid input")
-			continue
-		}
-
-		// return true for public repositories, false for private
-		return index == 1, nil
-	}
-}

bridge/github/config_test.go πŸ”—

@@ -7,7 +7,6 @@ import (
 	"github.com/stretchr/testify/assert"
 
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
-	"github.com/MichaelMure/git-bug/entity"
 )
 
 func TestSplitURL(t *testing.T) {
@@ -155,8 +154,8 @@ func TestValidateProject(t *testing.T) {
 		t.Skip("Env var GITHUB_TOKEN_PUBLIC missing")
 	}
 
-	tokenPrivate := auth.NewToken(entity.UnsetId, envPrivate, target)
-	tokenPublic := auth.NewToken(entity.UnsetId, envPublic, target)
+	tokenPrivate := auth.NewToken(envPrivate, target)
+	tokenPublic := auth.NewToken(envPublic, target)
 
 	type args struct {
 		owner   string

bridge/github/export.go πŸ”—

@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"os"
 	"strings"
 	"time"
 
@@ -19,7 +20,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/identity"
 )
 
 var (
@@ -74,7 +75,8 @@ func (ge *githubExporter) Init(repo *cache.RepoCache, conf core.Configuration) e
 		return err
 	}
 
-	creds, err := auth.List(repo, auth.WithUserId(user.Id()), auth.WithTarget(target), auth.WithKind(auth.KindToken))
+	login := user.ImmutableMetadata()[metaKeyGithubLogin]
+	creds, err := auth.List(repo, auth.WithMeta(auth.MetaKeyLogin, login), auth.WithTarget(target), auth.WithKind(auth.KindToken))
 	if err != nil {
 		return err
 	}
@@ -88,16 +90,30 @@ func (ge *githubExporter) Init(repo *cache.RepoCache, conf core.Configuration) e
 	return nil
 }
 
-func (ge *githubExporter) cacheAllClient(repo repository.RepoConfig) error {
+func (ge *githubExporter) cacheAllClient(repo *cache.RepoCache) error {
 	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 	if err != nil {
 		return err
 	}
 
 	for _, cred := range creds {
-		if _, ok := ge.identityClient[cred.UserId()]; !ok {
+		login, ok := cred.GetMetadata(auth.MetaKeyLogin)
+		if !ok {
+			_, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Github login\n", cred.ID().Human())
+			continue
+		}
+
+		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, login)
+		if err == identity.ErrIdentityNotExist {
+			continue
+		}
+		if err != nil {
+			return nil
+		}
+
+		if _, ok := ge.identityClient[user.Id()]; !ok {
 			client := buildClient(creds[0].(*auth.Token))
-			ge.identityClient[cred.UserId()] = client
+			ge.identityClient[user.Id()] = client
 		}
 	}
 
@@ -477,11 +493,12 @@ func (ge *githubExporter) cacheGithubLabels(ctx context.Context, gc *githubv4.Cl
 	for hasNextPage {
 		// create a new timeout context at each iteration
 		ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
-		defer cancel()
 
 		if err := gc.Query(ctx, &q, variables); err != nil {
+			cancel()
 			return err
 		}
+		cancel()
 
 		for _, label := range q.Repository.Labels.Nodes {
 			ge.cachedLabels[label.Name] = label.ID

bridge/github/export_test.go πŸ”—

@@ -144,8 +144,12 @@ func TestPushPull(t *testing.T) {
 	require.NoError(t, err)
 
 	// set author identity
+	login := "identity-test"
 	author, err := backend.NewIdentity("test identity", "test@test.org")
 	require.NoError(t, err)
+	author.SetMetadata(metaKeyGithubLogin, login)
+	err = author.Commit()
+	require.NoError(t, err)
 
 	err = backend.SetUserIdentity(author)
 	require.NoError(t, err)
@@ -153,6 +157,11 @@ func TestPushPull(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
+	token := auth.NewToken(envToken, target)
+	token.SetMetadata(auth.MetaKeyLogin, login)
+	err = auth.Store(repo, token)
+	require.NoError(t, err)
+
 	tests := testCases(t, backend)
 
 	// generate project name
@@ -176,10 +185,6 @@ func TestPushPull(t *testing.T) {
 		return deleteRepository(projectName, envUser, envToken)
 	})
 
-	token := auth.NewToken(author.Id(), envToken, target)
-	err = auth.Store(repo, token)
-	require.NoError(t, err)
-
 	// initialize exporter
 	exporter := &githubExporter{}
 	err = exporter.Init(backend, core.Configuration{
@@ -255,7 +260,7 @@ func TestPushPull(t *testing.T) {
 			// verify bug have same number of original operations
 			require.Len(t, importedBug.Snapshot().Operations, tt.numOrOp)
 
-			// verify bugs are taged with origin=github
+			// verify bugs are tagged with origin=github
 			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(core.MetaKeyOrigin)
 			require.True(t, ok)
 			require.Equal(t, issueOrigin, target)

bridge/github/github.go πŸ”—

@@ -3,6 +3,7 @@ package github
 
 import (
 	"context"
+	"time"
 
 	"github.com/shurcooL/githubv4"
 	"golang.org/x/oauth2"
@@ -11,12 +12,32 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 )
 
+const (
+	target = "github"
+
+	metaKeyGithubId    = "github-id"
+	metaKeyGithubUrl   = "github-url"
+	metaKeyGithubLogin = "github-login"
+
+	keyOwner   = "owner"
+	keyProject = "project"
+
+	githubV3Url    = "https://api.github.com"
+	defaultTimeout = 60 * time.Second
+)
+
+var _ core.BridgeImpl = &Github{}
+
 type Github struct{}
 
 func (*Github) Target() string {
 	return target
 }
 
+func (g *Github) LoginMetaKey() string {
+	return metaKeyGithubLogin
+}
+
 func (*Github) NewImporter() core.Importer {
 	return &githubImporter{}
 }

bridge/github/import.go πŸ”—

@@ -12,16 +12,9 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/text"
 )
 
-const (
-	metaKeyGithubId    = "github-id"
-	metaKeyGithubUrl   = "github-url"
-	metaKeyGithubLogin = "github-login"
-)
-
 // githubImporter implement the Importer interface
 type githubImporter struct {
 	conf core.Configuration
@@ -39,20 +32,7 @@ type githubImporter struct {
 func (gi *githubImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	gi.conf = conf
 
-	opts := []auth.Option{
-		auth.WithTarget(target),
-		auth.WithKind(auth.KindToken),
-	}
-
-	user, err := repo.GetUserIdentity()
-	if err == nil {
-		opts = append(opts, auth.WithUserId(user.Id()))
-	}
-	if err == identity.ErrNoIdentitySet {
-		opts = append(opts, auth.WithUserId(auth.DefaultUserId))
-	}
-
-	creds, err := auth.List(repo, opts...)
+	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 	if err != nil {
 		return err
 	}
@@ -554,10 +534,14 @@ func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*ca
 	case "Bot":
 	}
 
+	// Name is not necessarily set, fallback to login as a name is required in the identity
+	if name == "" {
+		name = string(actor.Login)
+	}
+
 	i, err = repo.NewIdentityRaw(
 		name,
 		email,
-		string(actor.Login),
 		string(actor.AvatarUrl),
 		map[string]string{
 			metaKeyGithubLogin: string(actor.Login),
@@ -604,7 +588,6 @@ func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache,
 	return repo.NewIdentityRaw(
 		name,
 		"",
-		string(q.User.Login),
 		string(q.User.AvatarUrl),
 		map[string]string{
 			metaKeyGithubLogin: string(q.User.Login),

bridge/github/import_test.go πŸ”—

@@ -21,6 +21,7 @@ import (
 
 func Test_Importer(t *testing.T) {
 	author := identity.NewIdentity("Michael MurΓ©", "batolettre@gmail.com")
+
 	tests := []struct {
 		name string
 		url  string
@@ -140,13 +141,11 @@ func Test_Importer(t *testing.T) {
 		t.Skip("Env var GITHUB_TOKEN_PRIVATE missing")
 	}
 
-	err = author.Commit(repo)
-	require.NoError(t, err)
-
-	err = identity.SetUserIdentity(repo, author)
-	require.NoError(t, err)
+	login := "test-identity"
+	author.SetMetadata(metaKeyGithubLogin, login)
 
-	token := auth.NewToken(author.Id(), envToken, target)
+	token := auth.NewToken(envToken, target)
+	token.SetMetadata(auth.MetaKeyLogin, login)
 	err = auth.Store(repo, token)
 	require.NoError(t, err)
 

bridge/gitlab/config.go πŸ”—

@@ -19,8 +19,7 @@ import (
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/input"
 	"github.com/MichaelMure/git-bug/repository"
 	"github.com/MichaelMure/git-bug/util/colors"
 )
@@ -36,14 +35,12 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 	if params.Owner != "" {
 		fmt.Println("warning: --owner is ineffective for a gitlab bridge")
 	}
+	if params.Login != "" {
+		fmt.Println("warning: --login is ineffective for a gitlab bridge")
+	}
 
 	conf := make(core.Configuration)
 	var err error
-
-	if (params.CredPrefix != "" || params.TokenRaw != "") && params.URL == "" {
-		return nil, fmt.Errorf("you must provide a project URL to configure this bridge with a token")
-	}
-
 	var baseUrl string
 
 	switch {
@@ -74,17 +71,6 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 		return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, url)
 	}
 
-	user, err := repo.GetUserIdentity()
-	if err != nil && err != identity.ErrNoIdentitySet {
-		return nil, err
-	}
-
-	// default to a "to be filled" user Id if we don't have a valid one yet
-	userId := auth.DefaultUserId
-	if user != nil {
-		userId = user.Id()
-	}
-
 	var cred auth.Credential
 
 	switch {
@@ -93,13 +79,16 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor
 		if err != nil {
 			return nil, err
 		}
-		if user != nil && cred.UserId() != user.Id() {
-			return nil, fmt.Errorf("selected credential don't match the user")
-		}
 	case params.TokenRaw != "":
-		cred = auth.NewToken(userId, params.TokenRaw, target)
+		token := auth.NewToken(params.TokenRaw, target)
+		login, err := getLoginFromToken(baseUrl, token)
+		if err != nil {
+			return nil, err
+		}
+		token.SetMetadata(auth.MetaKeyLogin, login)
+		cred = token
 	default:
-		cred, err = promptTokenOptions(repo, userId, baseUrl)
+		cred, err = promptTokenOptions(repo, baseUrl)
 		if err != nil {
 			return nil, err
 		}
@@ -153,77 +142,50 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
 }
 
 func promptBaseUrlOptions() (string, error) {
-	for {
-		fmt.Printf("Gitlab base url:\n")
-		fmt.Printf("[0]: https://gitlab.com\n")
-		fmt.Printf("[1]: enter your own base url\n")
-		fmt.Printf("Select option: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		if err != nil {
-			return "", err
-		}
+	index, err := input.PromptChoice("Gitlab base url", []string{
+		"https://gitlab.com",
+		"enter your own base url",
+	})
 
-		line = strings.TrimSpace(line)
-
-		index, err := strconv.Atoi(line)
-		if err != nil || index < 0 || index > 1 {
-			fmt.Println("invalid input")
-			continue
-		}
+	if err != nil {
+		return "", err
+	}
 
-		switch index {
-		case 0:
-			return defaultBaseURL, nil
-		case 1:
-			return promptBaseUrl()
-		}
+	if index == 0 {
+		return defaultBaseURL, nil
+	} else {
+		return promptBaseUrl()
 	}
 }
 
 func promptBaseUrl() (string, error) {
-	for {
-		fmt.Print("Base url: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+	validator := func(name string, value string) (string, error) {
+		u, err := url.Parse(value)
 		if err != nil {
-			return "", err
+			return err.Error(), nil
 		}
-
-		line = strings.TrimSpace(line)
-
-		ok, err := validateBaseUrl(line)
-		if err != nil {
-			return "", err
+		if u.Scheme == "" {
+			return "missing scheme", nil
 		}
-		if ok {
-			return line, nil
+		if u.Host == "" {
+			return "missing host", nil
 		}
+		return "", nil
 	}
-}
 
-func validateBaseUrl(baseUrl string) (bool, error) {
-	u, err := url.Parse(baseUrl)
-	if err != nil {
-		return false, err
-	}
-	return u.Scheme != "" && u.Host != "", nil
+	return input.Prompt("Base url", "url", input.Required, validator)
 }
 
-func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, baseUrl string) (auth.Credential, error) {
+func promptTokenOptions(repo repository.RepoConfig, baseUrl string) (auth.Credential, error) {
 	for {
-		creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target), auth.WithKind(auth.KindToken))
+		creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 		if err != nil {
 			return nil, err
 		}
 
 		// if we don't have existing token, fast-track to the token prompt
 		if len(creds) == 0 {
-			value, err := promptToken(baseUrl)
-			if err != nil {
-				return nil, err
-			}
-			return auth.NewToken(userId, value, target), nil
+			return promptToken(baseUrl)
 		}
 
 		fmt.Println()
@@ -261,44 +223,47 @@ func promptTokenOptions(repo repository.RepoConfig, userId entity.Id, baseUrl st
 
 		switch index {
 		case 1:
-			value, err := promptToken(baseUrl)
-			if err != nil {
-				return nil, err
-			}
-			return auth.NewToken(userId, value, target), nil
+			return promptToken(baseUrl)
 		default:
 			return creds[index-2], nil
 		}
 	}
 }
 
-func promptToken(baseUrl string) (string, error) {
+func promptToken(baseUrl string) (*auth.Token, error) {
 	fmt.Printf("You can generate a new token by visiting %s.\n", path.Join(baseUrl, "profile/personal_access_tokens"))
 	fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
 	fmt.Println()
 	fmt.Println("'api' access scope: to be able to make api calls")
 	fmt.Println()
 
-	re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}`)
+	re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}$`)
 	if err != nil {
 		panic("regexp compile:" + err.Error())
 	}
 
-	for {
-		fmt.Print("Enter token: ")
+	var login string
 
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		if err != nil {
-			return "", err
+	validator := func(name string, value string) (complaint string, err error) {
+		if !re.MatchString(value) {
+			return "token has incorrect format", nil
 		}
-
-		token := strings.TrimSpace(line)
-		if re.MatchString(token) {
-			return token, nil
+		login, err = getLoginFromToken(baseUrl, auth.NewToken(value, target))
+		if err != nil {
+			return fmt.Sprintf("token is invalid: %v", err), nil
 		}
+		return "", nil
+	}
 
-		fmt.Println("token has incorrect format")
+	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
+	if err != nil {
+		return nil, err
 	}
+
+	token := auth.NewToken(rawToken, target)
+	token.SetMetadata(auth.MetaKeyLogin, login)
+
+	return token, nil
 }
 
 func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) {
@@ -408,8 +373,25 @@ func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
 
 	project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
 	if err != nil {
-		return 0, errors.Wrap(err, "wrong token scope ou inexistent project")
+		return 0, errors.Wrap(err, "wrong token scope ou non-existent project")
 	}
 
 	return project.ID, nil
 }
+
+func getLoginFromToken(baseUrl string, token *auth.Token) (string, error) {
+	client, err := buildClient(baseUrl, token)
+	if err != nil {
+		return "", err
+	}
+
+	user, _, err := client.Users.CurrentUser()
+	if err != nil {
+		return "", err
+	}
+	if user.Username == "" {
+		return "", fmt.Errorf("gitlab say username is empty")
+	}
+
+	return user.Username, nil
+}

bridge/gitlab/export.go πŸ”—

@@ -3,6 +3,7 @@ package gitlab
 import (
 	"context"
 	"fmt"
+	"os"
 	"strconv"
 	"time"
 
@@ -14,7 +15,7 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/repository"
+	"github.com/MichaelMure/git-bug/identity"
 )
 
 var (
@@ -54,20 +55,33 @@ func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) e
 	return nil
 }
 
-func (ge *gitlabExporter) cacheAllClient(repo repository.RepoConfig) error {
+func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache) error {
 	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 	if err != nil {
 		return err
 	}
 
 	for _, cred := range creds {
-		if _, ok := ge.identityClient[cred.UserId()]; !ok {
+		login, ok := cred.GetMetadata(auth.MetaKeyLogin)
+		if !ok {
+			_, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Gitlab login\n", cred.ID().Human())
+			continue
+		}
+
+		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabLogin, login)
+		if err == identity.ErrIdentityNotExist {
+			continue
+		}
+		if err != nil {
+			return nil
+		}
+
+		if _, ok := ge.identityClient[user.Id()]; !ok {
 			client, err := buildClient(ge.conf[keyGitlabBaseUrl], creds[0].(*auth.Token))
 			if err != nil {
 				return err
 			}
-
-			ge.identityClient[cred.UserId()] = client
+			ge.identityClient[user.Id()] = client
 		}
 	}
 

bridge/gitlab/export_test.go πŸ”—

@@ -149,8 +149,12 @@ func TestPushPull(t *testing.T) {
 	require.NoError(t, err)
 
 	// set author identity
+	login := "test-identity"
 	author, err := backend.NewIdentity("test identity", "test@test.org")
 	require.NoError(t, err)
+	author.SetMetadata(metaKeyGitlabLogin, login)
+	err = author.Commit()
+	require.NoError(t, err)
 
 	err = backend.SetUserIdentity(author)
 	require.NoError(t, err)
@@ -158,12 +162,13 @@ func TestPushPull(t *testing.T) {
 	defer backend.Close()
 	interrupt.RegisterCleaner(backend.Close)
 
-	tests := testCases(t, backend)
-
-	token := auth.NewToken(author.Id(), envToken, target)
+	token := auth.NewToken(envToken, target)
+	token.SetMetadata(auth.MetaKeyLogin, login)
 	err = auth.Store(repo, token)
 	require.NoError(t, err)
 
+	tests := testCases(t, backend)
+
 	// generate project name
 	projectName := generateRepoName()
 
@@ -260,7 +265,7 @@ func TestPushPull(t *testing.T) {
 			// verify bug have same number of original operations
 			require.Len(t, importedBug.Snapshot().Operations, tt.numOpImp)
 
-			// verify bugs are taged with origin=gitlab
+			// verify bugs are tagged with origin=gitlab
 			issueOrigin, ok := importedBug.Snapshot().GetCreateMetadata(core.MetaKeyOrigin)
 			require.True(t, ok)
 			require.Equal(t, issueOrigin, target)

bridge/gitlab/gitlab.go πŸ”—

@@ -26,12 +26,18 @@ const (
 	defaultTimeout = 60 * time.Second
 )
 
+var _ core.BridgeImpl = &Gitlab{}
+
 type Gitlab struct{}
 
 func (*Gitlab) Target() string {
 	return target
 }
 
+func (g *Gitlab) LoginMetaKey() string {
+	return metaKeyGitlabLogin
+}
+
 func (*Gitlab) NewImporter() core.Importer {
 	return &gitlabImporter{}
 }

bridge/gitlab/import.go πŸ”—

@@ -13,7 +13,6 @@ import (
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/entity"
-	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/text"
 )
 
@@ -34,20 +33,7 @@ type gitlabImporter struct {
 func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 	gi.conf = conf
 
-	opts := []auth.Option{
-		auth.WithTarget(target),
-		auth.WithKind(auth.KindToken),
-	}
-
-	user, err := repo.GetUserIdentity()
-	if err == nil {
-		opts = append(opts, auth.WithUserId(user.Id()))
-	}
-	if err == identity.ErrNoIdentitySet {
-		opts = append(opts, auth.WithUserId(auth.DefaultUserId))
-	}
-
-	creds, err := auth.List(repo, opts...)
+	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 	if err != nil {
 		return err
 	}
@@ -403,7 +389,6 @@ func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.Id
 	i, err = repo.NewIdentityRaw(
 		user.Name,
 		user.PublicEmail,
-		user.Username,
 		user.AvatarURL,
 		map[string]string{
 			// because Gitlab

bridge/gitlab/import_test.go πŸ”—

@@ -21,6 +21,7 @@ import (
 
 func TestImport(t *testing.T) {
 	author := identity.NewIdentity("Amine Hilaly", "hilalyamine@gmail.com")
+
 	tests := []struct {
 		name string
 		url  string
@@ -94,13 +95,11 @@ func TestImport(t *testing.T) {
 		t.Skip("Env var GITLAB_PROJECT_ID missing")
 	}
 
-	err = author.Commit(repo)
-	require.NoError(t, err)
-
-	err = identity.SetUserIdentity(repo, author)
-	require.NoError(t, err)
+	login := "test-identity"
+	author.SetMetadata(metaKeyGitlabLogin, login)
 
-	token := auth.NewToken(author.Id(), envToken, target)
+	token := auth.NewToken(envToken, target)
+	token.SetMetadata(metaKeyGitlabLogin, login)
 	err = auth.Store(repo, token)
 	require.NoError(t, err)
 

bridge/launchpad/config.go πŸ”—

@@ -1,27 +1,18 @@
 package launchpad
 
 import (
-	"bufio"
 	"errors"
 	"fmt"
 	"net/http"
-	"os"
 	"regexp"
-	"strings"
-	"time"
 
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/input"
 )
 
 var ErrBadProjectURL = errors.New("bad Launchpad project URL")
 
-const (
-	target         = "launchpad-preview"
-	keyProject     = "project"
-	defaultTimeout = 60 * time.Second
-)
-
 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")
@@ -45,7 +36,7 @@ func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (
 		project, err = splitURL(params.URL)
 	default:
 		// get project name from terminal prompt
-		project, err = promptProjectName()
+		project, err = input.Prompt("Launchpad project name", "project name", input.Required)
 	}
 
 	if err != nil {
@@ -86,26 +77,6 @@ func (*Launchpad) ValidateConfig(conf core.Configuration) error {
 	return nil
 }
 
-func promptProjectName() (string, error) {
-	for {
-		fmt.Print("Launchpad project name: ")
-
-		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
-		if err != nil {
-			return "", err
-		}
-
-		line = strings.TrimRight(line, "\n")
-
-		if line == "" {
-			fmt.Println("Project name is empty")
-			continue
-		}
-
-		return line, nil
-	}
-}
-
 func validateProject(project string) (bool, error) {
 	url := fmt.Sprintf("%s/%s", apiRoot, project)
 

bridge/launchpad/import.go πŸ”—

@@ -20,11 +20,6 @@ func (li *launchpadImporter) Init(repo *cache.RepoCache, conf core.Configuration
 	return nil
 }
 
-const (
-	metaKeyLaunchpadID    = "launchpad-id"
-	metaKeyLaunchpadLogin = "launchpad-login"
-)
-
 func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson) (*cache.IdentityCache, error) {
 	// Look first in the cache
 	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyLaunchpadLogin, owner.Login)
@@ -38,7 +33,6 @@ func (li *launchpadImporter) ensurePerson(repo *cache.RepoCache, owner LPPerson)
 	return repo.NewIdentityRaw(
 		owner.Name,
 		"",
-		owner.Login,
 		"",
 		map[string]string{
 			metaKeyLaunchpadLogin: owner.Login,

bridge/launchpad/launchpad.go πŸ”—

@@ -2,15 +2,34 @@
 package launchpad
 
 import (
+	"time"
+
 	"github.com/MichaelMure/git-bug/bridge/core"
 )
 
+const (
+	target = "launchpad-preview"
+
+	metaKeyLaunchpadID    = "launchpad-id"
+	metaKeyLaunchpadLogin = "launchpad-login"
+
+	keyProject = "project"
+
+	defaultTimeout = 60 * time.Second
+)
+
+var _ core.BridgeImpl = &Launchpad{}
+
 type Launchpad struct{}
 
 func (*Launchpad) Target() string {
 	return "launchpad-preview"
 }
 
+func (l *Launchpad) LoginMetaKey() string {
+	return metaKeyLaunchpadLogin
+}
+
 func (*Launchpad) NewImporter() core.Importer {
 	return &launchpadImporter{}
 }

bug/status.go πŸ”—

@@ -44,7 +44,7 @@ func StatusFromString(str string) (Status, error) {
 	case "closed":
 		return ClosedStatus, nil
 	default:
-		return 0, fmt.Errorf("unknow status")
+		return 0, fmt.Errorf("unknown status")
 	}
 }
 

cache/bug_excerpt.go πŸ”—

@@ -2,7 +2,6 @@ package cache
 
 import (
 	"encoding/gob"
-	"fmt"
 
 	"github.com/MichaelMure/git-bug/bug"
 	"github.com/MichaelMure/git-bug/entity"
@@ -43,21 +42,11 @@ type BugExcerpt struct {
 
 // identity.Bare data are directly embedded in the bug excerpt
 type LegacyAuthorExcerpt struct {
-	Name  string
-	Login string
+	Name string
 }
 
 func (l LegacyAuthorExcerpt) DisplayName() string {
-	switch {
-	case l.Name == "" && l.Login != "":
-		return l.Login
-	case l.Name != "" && l.Login == "":
-		return l.Name
-	case l.Name != "" && l.Login != "":
-		return fmt.Sprintf("%s (%s)", l.Name, l.Login)
-	}
-
-	panic("invalid person data")
+	return l.Name
 }
 
 func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
@@ -95,8 +84,7 @@ func NewBugExcerpt(b bug.Interface, snap *bug.Snapshot) *BugExcerpt {
 		e.AuthorId = snap.Author.Id()
 	case *identity.Bare:
 		e.LegacyAuthor = LegacyAuthorExcerpt{
-			Login: snap.Author.Login(),
-			Name:  snap.Author.Name(),
+			Name: snap.Author.Name(),
 		}
 	default:
 		panic("unhandled identity type")

cache/filter.go πŸ”—

@@ -37,8 +37,7 @@ func AuthorFilter(query string) Filter {
 		}
 
 		// Legacy identity support
-		return strings.Contains(strings.ToLower(excerpt.LegacyAuthor.Name), query) ||
-			strings.Contains(strings.ToLower(excerpt.LegacyAuthor.Login), query)
+		return strings.Contains(strings.ToLower(excerpt.LegacyAuthor.Name), query)
 	}
 }
 

cache/identity_cache.go πŸ”—

@@ -21,8 +21,8 @@ func (i *IdentityCache) notifyUpdated() error {
 	return i.repoCache.identityUpdated(i.Identity.Id())
 }
 
-func (i *IdentityCache) AddVersion(version *identity.Version) error {
-	i.Identity.AddVersion(version)
+func (i *IdentityCache) Mutate(f func(identity.Mutator) identity.Mutator) error {
+	i.Identity.Mutate(f)
 	return i.notifyUpdated()
 }
 

cache/identity_excerpt.go πŸ”—

@@ -2,7 +2,6 @@ package cache
 
 import (
 	"encoding/gob"
-	"fmt"
 	"strings"
 
 	"github.com/MichaelMure/git-bug/entity"
@@ -21,7 +20,6 @@ type IdentityExcerpt struct {
 	Id entity.Id
 
 	Name              string
-	Login             string
 	ImmutableMetadata map[string]string
 }
 
@@ -29,7 +27,6 @@ func NewIdentityExcerpt(i *identity.Identity) *IdentityExcerpt {
 	return &IdentityExcerpt{
 		Id:                i.Id(),
 		Name:              i.Name(),
-		Login:             i.Login(),
 		ImmutableMetadata: i.ImmutableMetadata(),
 	}
 }
@@ -37,23 +34,13 @@ func NewIdentityExcerpt(i *identity.Identity) *IdentityExcerpt {
 // DisplayName return a non-empty string to display, representing the
 // identity, based on the non-empty values.
 func (i *IdentityExcerpt) DisplayName() string {
-	switch {
-	case i.Name == "" && i.Login != "":
-		return i.Login
-	case i.Name != "" && i.Login == "":
-		return i.Name
-	case i.Name != "" && i.Login != "":
-		return fmt.Sprintf("%s (%s)", i.Name, i.Login)
-	}
-
-	panic("invalid person data")
+	return i.Name
 }
 
 // Match matches a query with the identity name, login and ID prefixes
 func (i *IdentityExcerpt) Match(query string) bool {
 	return i.Id.HasPrefix(query) ||
-		strings.Contains(strings.ToLower(i.Name), query) ||
-		strings.Contains(strings.ToLower(i.Login), query)
+		strings.Contains(strings.ToLower(i.Name), query)
 }
 
 /*

cache/query.go πŸ”—

@@ -91,7 +91,7 @@ func ParseQuery(query string) (*Query, error) {
 			sortingDone = true
 
 		default:
-			return nil, fmt.Errorf("unknow qualifier name %s", qualifierName)
+			return nil, fmt.Errorf("unknown qualifier name %s", qualifierName)
 		}
 	}
 
@@ -165,7 +165,7 @@ func (q *Query) parseSorting(query string) error {
 		q.OrderDirection = OrderAscending
 
 	default:
-		return fmt.Errorf("unknow sorting %s", query)
+		return fmt.Errorf("unknown sorting %s", query)
 	}
 
 	return nil

cache/repo_cache.go πŸ”—

@@ -409,36 +409,27 @@ func (c *RepoCache) ResolveBugExcerpt(id entity.Id) (*BugExcerpt, error) {
 // ResolveBugPrefix retrieve a bug matching an id prefix. It fails if multiple
 // bugs match.
 func (c *RepoCache) ResolveBugPrefix(prefix string) (*BugCache, error) {
-	// preallocate but empty
-	matching := make([]entity.Id, 0, 5)
-
-	for id := range c.bugExcerpts {
-		if id.HasPrefix(prefix) {
-			matching = append(matching, id)
-		}
-	}
-
-	if len(matching) > 1 {
-		return nil, bug.NewErrMultipleMatchBug(matching)
-	}
-
-	if len(matching) == 0 {
-		return nil, bug.ErrBugNotExist
-	}
-
-	return c.ResolveBug(matching[0])
+	return c.ResolveBugMatcher(func(excerpt *BugExcerpt) bool {
+		return excerpt.Id.HasPrefix(prefix)
+	})
 }
 
 // ResolveBugCreateMetadata retrieve a bug that has the exact given metadata on
 // its Create operation, that is, the first operation. It fails if multiple bugs
 // match.
 func (c *RepoCache) ResolveBugCreateMetadata(key string, value string) (*BugCache, error) {
+	return c.ResolveBugMatcher(func(excerpt *BugExcerpt) bool {
+		return excerpt.CreateMetadata[key] == value
+	})
+}
+
+func (c *RepoCache) ResolveBugMatcher(f func(*BugExcerpt) bool) (*BugCache, error) {
 	// preallocate but empty
 	matching := make([]entity.Id, 0, 5)
 
-	for id, excerpt := range c.bugExcerpts {
-		if excerpt.CreateMetadata[key] == value {
-			matching = append(matching, id)
+	for _, excerpt := range c.bugExcerpts {
+		if f(excerpt) {
+			matching = append(matching, excerpt.Id)
 		}
 	}
 
@@ -785,35 +776,26 @@ func (c *RepoCache) ResolveIdentityExcerpt(id entity.Id) (*IdentityExcerpt, erro
 // ResolveIdentityPrefix retrieve an Identity matching an id prefix.
 // It fails if multiple identities match.
 func (c *RepoCache) ResolveIdentityPrefix(prefix string) (*IdentityCache, error) {
-	// preallocate but empty
-	matching := make([]entity.Id, 0, 5)
-
-	for id := range c.identitiesExcerpts {
-		if id.HasPrefix(prefix) {
-			matching = append(matching, id)
-		}
-	}
-
-	if len(matching) > 1 {
-		return nil, identity.NewErrMultipleMatch(matching)
-	}
-
-	if len(matching) == 0 {
-		return nil, identity.ErrIdentityNotExist
-	}
-
-	return c.ResolveIdentity(matching[0])
+	return c.ResolveIdentityMatcher(func(excerpt *IdentityExcerpt) bool {
+		return excerpt.Id.HasPrefix(prefix)
+	})
 }
 
 // ResolveIdentityImmutableMetadata retrieve an Identity that has the exact given metadata on
 // one of it's version. If multiple version have the same key, the first defined take precedence.
 func (c *RepoCache) ResolveIdentityImmutableMetadata(key string, value string) (*IdentityCache, error) {
+	return c.ResolveIdentityMatcher(func(excerpt *IdentityExcerpt) bool {
+		return excerpt.ImmutableMetadata[key] == value
+	})
+}
+
+func (c *RepoCache) ResolveIdentityMatcher(f func(*IdentityExcerpt) bool) (*IdentityCache, error) {
 	// preallocate but empty
 	matching := make([]entity.Id, 0, 5)
 
-	for id, i := range c.identitiesExcerpts {
-		if i.ImmutableMetadata[key] == value {
-			matching = append(matching, id)
+	for _, excerpt := range c.identitiesExcerpts {
+		if f(excerpt) {
+			matching = append(matching, excerpt.Id)
 		}
 	}
 
@@ -881,21 +863,36 @@ func (c *RepoCache) IsUserIdentitySet() (bool, error) {
 	return identity.IsUserIdentitySet(c.repo)
 }
 
+func (c *RepoCache) NewIdentityFromGitUser() (*IdentityCache, error) {
+	return c.NewIdentityFromGitUserRaw(nil)
+}
+
+func (c *RepoCache) NewIdentityFromGitUserRaw(metadata map[string]string) (*IdentityCache, error) {
+	i, err := identity.NewFromGitUser(c.repo)
+	if err != nil {
+		return nil, err
+	}
+	return c.finishIdentity(i, metadata)
+}
+
 // NewIdentity create a new identity
 // The new identity is written in the repository (commit)
 func (c *RepoCache) NewIdentity(name string, email string) (*IdentityCache, error) {
-	return c.NewIdentityRaw(name, email, "", "", nil)
+	return c.NewIdentityRaw(name, email, "", nil)
 }
 
 // NewIdentityFull create a new identity
 // The new identity is written in the repository (commit)
-func (c *RepoCache) NewIdentityFull(name string, email string, login string, avatarUrl string) (*IdentityCache, error) {
-	return c.NewIdentityRaw(name, email, login, avatarUrl, nil)
+func (c *RepoCache) NewIdentityFull(name string, email string, avatarUrl string) (*IdentityCache, error) {
+	return c.NewIdentityRaw(name, email, avatarUrl, nil)
 }
 
-func (c *RepoCache) NewIdentityRaw(name string, email string, login string, avatarUrl string, metadata map[string]string) (*IdentityCache, error) {
-	i := identity.NewIdentityFull(name, email, login, avatarUrl)
+func (c *RepoCache) NewIdentityRaw(name string, email string, avatarUrl string, metadata map[string]string) (*IdentityCache, error) {
+	i := identity.NewIdentityFull(name, email, avatarUrl)
+	return c.finishIdentity(i, metadata)
+}
 
+func (c *RepoCache) finishIdentity(i *identity.Identity, metadata map[string]string) (*IdentityCache, error) {
 	for key, value := range metadata {
 		i.SetMetadata(key, value)
 	}

commands/bridge_auth.go πŸ”—

@@ -2,6 +2,8 @@ package commands
 
 import (
 	"fmt"
+	"sort"
+	"strings"
 
 	"github.com/spf13/cobra"
 
@@ -26,8 +28,6 @@ func runBridgeAuth(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	defaultUser, _ := backend.GetUserIdentity()
-
 	for _, cred := range creds {
 		targetFmt := text.LeftPadMaxLine(cred.Target(), 10, 0)
 
@@ -37,29 +37,19 @@ func runBridgeAuth(cmd *cobra.Command, args []string) error {
 			value = cred.Value
 		}
 
-		var userFmt string
-
-		switch cred.UserId() {
-		case auth.DefaultUserId:
-			userFmt = colors.Red("default user")
-		default:
-			user, err := backend.ResolveIdentity(cred.UserId())
-			if err != nil {
-				return err
-			}
-			userFmt = user.DisplayName()
-
-			if cred.UserId() == defaultUser.Id() {
-				userFmt = colors.Red(userFmt)
-			}
+		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("%s %s %s %s %s\n",
 			colors.Cyan(cred.ID().Human()),
 			colors.Yellow(targetFmt),
 			colors.Magenta(cred.Kind()),
-			userFmt,
 			value,
+			metaFmt,
 		)
 	}
 

commands/bridge_auth_addtoken.go πŸ”—

@@ -13,24 +13,37 @@ import (
 	"github.com/MichaelMure/git-bug/bridge"
 	"github.com/MichaelMure/git-bug/bridge/core"
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
-	"github.com/MichaelMure/git-bug/identity"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/MichaelMure/git-bug/util/interrupt"
 )
 
 var (
 	bridgeAuthAddTokenTarget string
+	bridgeAuthAddTokenLogin  string
+	bridgeAuthAddTokenUser   string
 )
 
 func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
-	var value string
-
 	if bridgeAuthAddTokenTarget == "" {
 		return fmt.Errorf("flag --target is required")
 	}
+	if bridgeAuthAddTokenLogin == "" {
+		return fmt.Errorf("flag --login is required")
+	}
+
+	backend, err := cache.NewRepoCache(repo)
+	if err != nil {
+		return err
+	}
+	defer backend.Close()
+	interrupt.RegisterCleaner(backend.Close)
 
 	if !core.TargetExist(bridgeAuthAddTokenTarget) {
 		return fmt.Errorf("unknown target")
 	}
 
+	var value string
+
 	if len(args) == 1 {
 		value = args[0]
 	} else {
@@ -46,12 +59,36 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error {
 		value = strings.TrimSuffix(raw, "\n")
 	}
 
-	user, err := identity.GetUserIdentity(repo)
+	var user *cache.IdentityCache
+
+	if bridgeAuthAddTokenUser == "" {
+		user, err = backend.GetUserIdentity()
+	} else {
+		user, err = backend.ResolveIdentityPrefix(bridgeAuthAddTokenUser)
+	}
 	if err != nil {
 		return err
 	}
 
-	token := auth.NewToken(user.Id(), value, bridgeAuthAddTokenTarget)
+	metaKey, _ := bridge.LoginMetaKey(bridgeAuthAddTokenTarget)
+	login, ok := user.ImmutableMetadata()[metaKey]
+
+	switch {
+	case ok && login == bridgeAuthAddTokenLogin:
+		// nothing to do
+	case ok && login != bridgeAuthAddTokenLogin:
+		return fmt.Errorf("this user is already tagged with a different %s login", bridgeAuthAddTokenTarget)
+	default:
+		user.SetMetadata(metaKey, bridgeAuthAddTokenLogin)
+		err = user.Commit()
+		if err != nil {
+			return err
+		}
+	}
+
+	token := auth.NewToken(value, bridgeAuthAddTokenTarget)
+	token.SetMetadata(auth.MetaKeyLogin, bridgeAuthAddTokenLogin)
+
 	if err := token.Validate(); err != nil {
 		return errors.Wrap(err, "invalid token")
 	}
@@ -77,5 +114,9 @@ func init() {
 	bridgeAuthCmd.AddCommand(bridgeAuthAddTokenCmd)
 	bridgeAuthAddTokenCmd.Flags().StringVarP(&bridgeAuthAddTokenTarget, "target", "t", "",
 		fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ",")))
+	bridgeAuthAddTokenCmd.Flags().StringVarP(&bridgeAuthAddTokenLogin,
+		"login", "l", "", "The login in the remote bug-tracker")
+	bridgeAuthAddTokenCmd.Flags().StringVarP(&bridgeAuthAddTokenUser,
+		"user", "u", "", "The user to add the token to. Default is the current user")
 	bridgeAuthAddTokenCmd.Flags().SortFlags = false
 }

commands/bridge_auth_show.go πŸ”—

@@ -2,13 +2,14 @@ package commands
 
 import (
 	"fmt"
+	"sort"
+	"strings"
 	"time"
 
 	"github.com/spf13/cobra"
 
 	"github.com/MichaelMure/git-bug/bridge/core/auth"
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/util/colors"
 	"github.com/MichaelMure/git-bug/util/interrupt"
 )
 
@@ -25,28 +26,9 @@ func runBridgeAuthShow(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	var userFmt string
-
-	switch cred.UserId() {
-	case auth.DefaultUserId:
-		userFmt = colors.Red("default user")
-	default:
-		user, err := backend.ResolveIdentity(cred.UserId())
-		if err != nil {
-			return err
-		}
-		userFmt = user.DisplayName()
-
-		defaultUser, _ := backend.GetUserIdentity()
-		if cred.UserId() == defaultUser.Id() {
-			userFmt = colors.Red(userFmt)
-		}
-	}
-
 	fmt.Printf("Id: %s\n", cred.ID())
 	fmt.Printf("Target: %s\n", cred.Target())
 	fmt.Printf("Kind: %s\n", cred.Kind())
-	fmt.Printf("User: %s\n", userFmt)
 	fmt.Printf("Creation: %s\n", cred.CreateTime().Format(time.RFC822))
 
 	switch cred := cred.(type) {
@@ -54,6 +36,16 @@ func runBridgeAuthShow(cmd *cobra.Command, args []string) error {
 		fmt.Printf("Value: %s\n", cred.Value)
 	}
 
+	fmt.Println("Metadata:")
+
+	meta := make([]string, 0, len(cred.Metadata()))
+	for key, value := range cred.Metadata() {
+		meta = append(meta, fmt.Sprintf("    %s --> %s\n", key, value))
+	}
+	sort.Strings(meta)
+
+	fmt.Print(strings.Join(meta, ""))
+
 	return nil
 }
 

commands/user.go πŸ”—

@@ -50,8 +50,6 @@ func runUser(cmd *cobra.Command, args []string) error {
 				Time().Format("Mon Jan 2 15:04:05 2006 +0200"))
 		case "lastModificationLamport":
 			fmt.Printf("%d\n", id.LastModificationLamport())
-		case "login":
-			fmt.Printf("%s\n", id.Login())
 		case "metadata":
 			for key, value := range id.ImmutableMetadata() {
 				fmt.Printf("%s\n%s\n", key, value)
@@ -68,7 +66,6 @@ func runUser(cmd *cobra.Command, args []string) error {
 
 	fmt.Printf("Id: %s\n", id.Id())
 	fmt.Printf("Name: %s\n", id.Name())
-	fmt.Printf("Login: %s\n", id.Login())
 	fmt.Printf("Email: %s\n", id.Email())
 	fmt.Printf("Last modification: %s (lamport %d)\n",
 		id.LastModification().Time().Format("Mon Jan 2 15:04:05 2006 +0200"),

commands/user_adopt.go πŸ”—

@@ -4,11 +4,10 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/MichaelMure/git-bug/bridge/core/auth"
+	"github.com/spf13/cobra"
+
 	"github.com/MichaelMure/git-bug/cache"
-	"github.com/MichaelMure/git-bug/identity"
 	"github.com/MichaelMure/git-bug/util/interrupt"
-	"github.com/spf13/cobra"
 )
 
 func runUserAdopt(cmd *cobra.Command, args []string) error {
@@ -26,16 +25,6 @@ func runUserAdopt(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	_, err = backend.GetUserIdentity()
-	if err == identity.ErrNoIdentitySet {
-		err = auth.ReplaceDefaultUser(repo, i.Id())
-		if err != nil {
-			return err
-		}
-	} else if err != nil {
-		return err
-	}
-
 	err = backend.SetUserIdentity(i)
 	if err != nil {
 		return err

commands/user_create.go πŸ”—

@@ -23,7 +23,7 @@ func runUserCreate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	name, err := input.PromptValueRequired("Name", preName)
+	name, err := input.PromptDefault("Name", "name", preName, input.Required)
 	if err != nil {
 		return err
 	}
@@ -33,17 +33,17 @@ func runUserCreate(cmd *cobra.Command, args []string) error {
 		return err
 	}
 
-	email, err := input.PromptValueRequired("Email", preEmail)
+	email, err := input.PromptDefault("Email", "email", preEmail, input.Required)
 	if err != nil {
 		return err
 	}
 
-	login, err := input.PromptValue("Avatar URL", "")
+	avatarURL, err := input.Prompt("Avatar URL", "avatar")
 	if err != nil {
 		return err
 	}
 
-	id, err := backend.NewIdentityRaw(name, email, "", login, nil)
+	id, err := backend.NewIdentityRaw(name, email, avatarURL, nil)
 	if err != nil {
 		return err
 	}

doc/man/git-bug-bridge-auth-add-token.1 πŸ”—

@@ -23,6 +23,14 @@ Store a new token
 \fB\-t\fP, \fB\-\-target\fP=""
     The target of the bridge. Valid values are [github,gitlab,launchpad\-preview]
 
+.PP
+\fB\-l\fP, \fB\-\-login\fP=""
+    The login in the remote bug\-tracker
+
+.PP
+\fB\-u\fP, \fB\-\-user\fP=""
+    The user to add the token to. Default is the current user
+
 .PP
 \fB\-h\fP, \fB\-\-help\fP[=false]
     help for add\-token

doc/md/git-bug_bridge_auth_add-token.md πŸ”—

@@ -14,6 +14,8 @@ git-bug bridge auth add-token [<token>] [flags]
 
 ```
   -t, --target string   The target of the bridge. Valid values are [github,gitlab,launchpad-preview]
+  -l, --login string    The login in the remote bug-tracker
+  -u, --user string     The user to add the token to. Default is the current user
   -h, --help            help for add-token
 ```
 

go.mod πŸ”—

@@ -25,7 +25,7 @@ require (
 	github.com/spf13/cobra v0.0.5
 	github.com/stretchr/testify v1.4.0
 	github.com/theckman/goconstraint v1.11.0
-	github.com/vektah/gqlparser v1.2.1
+	github.com/vektah/gqlparser v1.3.1
 	github.com/xanzy/go-gitlab v0.24.0
 	golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
 	golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288

go.sum πŸ”—

@@ -131,6 +131,8 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
 github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
 github.com/vektah/gqlparser v1.2.1 h1:C+L7Go/eUbN0w6Y0kaiq2W6p2wN5j8wU82EdDXxDivc=
 github.com/vektah/gqlparser v1.2.1/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
+github.com/vektah/gqlparser v1.3.1 h1:8b0IcD3qZKWJQHSzynbDlrtP3IxVydZ2DZepCGofqfU=
+github.com/vektah/gqlparser v1.3.1/go.mod h1:bkVf0FX+Stjg/MHnm8mEyubuaArhNEqfQhF+OTiAL74=
 github.com/xanzy/go-gitlab v0.22.1 h1:TVxgHmoa35jQL+9FCkG0nwPDxU9dQZXknBTDtGaSFno=
 github.com/xanzy/go-gitlab v0.22.1/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og=
 github.com/xanzy/go-gitlab v0.24.0 h1:zP1zC4K76Gha0coN5GhygOLhsHTCvUjrnqGL3kHXkVU=

graphql/graph/gen_graph.go πŸ”—

@@ -210,7 +210,6 @@ type ComplexityRoot struct {
 		HumanID     func(childComplexity int) int
 		ID          func(childComplexity int) int
 		IsProtected func(childComplexity int) int
-		Login       func(childComplexity int) int
 		Name        func(childComplexity int) int
 	}
 
@@ -1139,13 +1138,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 
 		return e.complexity.Identity.IsProtected(childComplexity), true
 
-	case "Identity.login":
-		if e.complexity.Identity.Login == nil {
-			break
-		}
-
-		return e.complexity.Identity.Login(childComplexity), true
-
 	case "Identity.name":
 		if e.complexity.Identity.Name == nil {
 			break
@@ -2319,11 +2311,7 @@ type Identity {
 	"""
 	email: String
 	"""
-	The login of the person, if known.
-	"""
-	login: String
-	"""
-	A string containing the either the name of the person, its login or both
+	A non-empty string to display, representing the identity, based on the non-empty values.
 	"""
 	displayName: String!
 	"""
@@ -6215,37 +6203,6 @@ func (ec *executionContext) _Identity_email(ctx context.Context, field graphql.C
 	return ec.marshalOString2string(ctx, field.Selections, res)
 }
 
-func (ec *executionContext) _Identity_login(ctx context.Context, field graphql.CollectedField, obj identity.Interface) (ret graphql.Marshaler) {
-	defer func() {
-		if r := recover(); r != nil {
-			ec.Error(ctx, ec.Recover(ctx, r))
-			ret = graphql.Null
-		}
-	}()
-	fc := &graphql.FieldContext{
-		Object:   "Identity",
-		Field:    field,
-		Args:     nil,
-		IsMethod: true,
-	}
-
-	ctx = graphql.WithFieldContext(ctx, fc)
-	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
-		ctx = rctx // use context from middleware stack in children
-		return obj.Login(), nil
-	})
-	if err != nil {
-		ec.Error(ctx, err)
-		return graphql.Null
-	}
-	if resTmp == nil {
-		return graphql.Null
-	}
-	res := resTmp.(string)
-	fc.Result = res
-	return ec.marshalOString2string(ctx, field.Selections, res)
-}
-
 func (ec *executionContext) _Identity_displayName(ctx context.Context, field graphql.CollectedField, obj identity.Interface) (ret graphql.Marshaler) {
 	defer func() {
 		if r := recover(); r != nil {
@@ -11946,8 +11903,6 @@ func (ec *executionContext) _Identity(ctx context.Context, sel ast.SelectionSet,
 			out.Values[i] = ec._Identity_name(ctx, field, obj)
 		case "email":
 			out.Values[i] = ec._Identity_email(ctx, field, obj)
-		case "login":
-			out.Values[i] = ec._Identity_login(ctx, field, obj)
 		case "displayName":
 			out.Values[i] = ec._Identity_displayName(ctx, field, obj)
 			if out.Values[i] == graphql.Null {

graphql/resolvers/identity.go πŸ”—

@@ -15,6 +15,7 @@ func (identityResolver) ID(ctx context.Context, obj identity.Interface) (string,
 	return obj.Id().String(), nil
 }
 
-func (identityResolver) HumanID(ctx context.Context, obj identity.Interface) (string, error) {
+func (r identityResolver) HumanID(ctx context.Context, obj identity.Interface) (string, error) {
 	return obj.Id().Human(), nil
+
 }

graphql/schema/identity.graphql πŸ”—

@@ -8,9 +8,7 @@ type Identity {
     name: String
     """The email of the person, if known."""
     email: String
-    """The login of the person, if known."""
-    login: String
-    """A string containing the either the name of the person, its login or both"""
+    """A non-empty string to display, representing the identity, based on the non-empty values."""
     displayName: String!
     """An url to an avatar"""
     avatarUrl: String

graphql/schema/root.graphql πŸ”—

@@ -3,6 +3,8 @@ type Query {
     defaultRepository: Repository
     """Access a repository by reference/name."""
     repository(ref: String!): Repository
+
+    #TODO: connection for all repositories
 }
 
 type Mutation {

identity/bare.go πŸ”—

@@ -25,7 +25,6 @@ type Bare struct {
 	id        entity.Id
 	name      string
 	email     string
-	login     string
 	avatarUrl string
 }
 
@@ -33,8 +32,8 @@ func NewBare(name string, email string) *Bare {
 	return &Bare{id: entity.UnsetId, name: name, email: email}
 }
 
-func NewBareFull(name string, email string, login string, avatarUrl string) *Bare {
-	return &Bare{id: entity.UnsetId, name: name, email: email, login: login, avatarUrl: avatarUrl}
+func NewBareFull(name string, email string, avatarUrl string) *Bare {
+	return &Bare{id: entity.UnsetId, name: name, email: email, avatarUrl: avatarUrl}
 }
 
 func deriveId(data []byte) entity.Id {
@@ -45,7 +44,7 @@ func deriveId(data []byte) entity.Id {
 type bareIdentityJSON struct {
 	Name      string `json:"name,omitempty"`
 	Email     string `json:"email,omitempty"`
-	Login     string `json:"login,omitempty"`
+	Login     string `json:"login,omitempty"` // Deprecated, only kept to have the same ID when reading an old value
 	AvatarUrl string `json:"avatar_url,omitempty"`
 }
 
@@ -53,7 +52,6 @@ func (i *Bare) MarshalJSON() ([]byte, error) {
 	return json.Marshal(bareIdentityJSON{
 		Name:      i.name,
 		Email:     i.email,
-		Login:     i.login,
 		AvatarUrl: i.avatarUrl,
 	})
 }
@@ -70,7 +68,6 @@ func (i *Bare) UnmarshalJSON(data []byte) error {
 
 	i.name = aux.Name
 	i.email = aux.Email
-	i.login = aux.Login
 	i.avatarUrl = aux.AvatarUrl
 
 	return nil
@@ -109,45 +106,31 @@ func (i *Bare) Email() string {
 	return i.email
 }
 
-// Login return the last version of the login
-func (i *Bare) Login() string {
-	return i.login
-}
-
 // AvatarUrl return the last version of the Avatar URL
 func (i *Bare) AvatarUrl() string {
 	return i.avatarUrl
 }
 
 // Keys return the last version of the valid keys
-func (i *Bare) Keys() []Key {
-	return []Key{}
+func (i *Bare) Keys() []*Key {
+	return nil
 }
 
 // ValidKeysAtTime return the set of keys valid at a given lamport time
-func (i *Bare) ValidKeysAtTime(time lamport.Time) []Key {
-	return []Key{}
+func (i *Bare) ValidKeysAtTime(_ lamport.Time) []*Key {
+	return nil
 }
 
 // DisplayName return a non-empty string to display, representing the
 // identity, based on the non-empty values.
 func (i *Bare) DisplayName() string {
-	switch {
-	case i.name == "" && i.login != "":
-		return i.login
-	case i.name != "" && i.login == "":
-		return i.name
-	case i.name != "" && i.login != "":
-		return fmt.Sprintf("%s (%s)", i.name, i.login)
-	}
-
-	panic("invalid person data")
+	return i.name
 }
 
 // Validate check if the Identity data is valid
 func (i *Bare) Validate() error {
-	if text.Empty(i.name) && text.Empty(i.login) {
-		return fmt.Errorf("either name or login should be set")
+	if text.Empty(i.name) {
+		return fmt.Errorf("name is not set")
 	}
 
 	if strings.Contains(i.name, "\n") {
@@ -158,14 +141,6 @@ func (i *Bare) Validate() error {
 		return fmt.Errorf("name is not fully printable")
 	}
 
-	if strings.Contains(i.login, "\n") {
-		return fmt.Errorf("login should be a single line")
-	}
-
-	if !text.Safe(i.login) {
-		return fmt.Errorf("login is not fully printable")
-	}
-
 	if strings.Contains(i.email, "\n") {
 		return fmt.Errorf("email should be a single line")
 	}

identity/bare_test.go πŸ”—

@@ -18,7 +18,6 @@ func TestBare_Id(t *testing.T) {
 
 func TestBareSerialize(t *testing.T) {
 	before := &Bare{
-		login:     "login",
 		email:     "email",
 		name:      "name",
 		avatarUrl: "avatar",

identity/common.go πŸ”—

@@ -37,7 +37,7 @@ func UnmarshalJSON(raw json.RawMessage) (Interface, error) {
 	b := &Bare{}
 
 	err = json.Unmarshal(raw, b)
-	if err == nil && (b.name != "" || b.login != "") {
+	if err == nil && b.name != "" {
 		return b, nil
 	}
 

identity/identity.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+	"reflect"
 	"strings"
 	"time"
 
@@ -55,14 +56,13 @@ func NewIdentity(name string, email string) *Identity {
 	}
 }
 
-func NewIdentityFull(name string, email string, login string, avatarUrl string) *Identity {
+func NewIdentityFull(name string, email string, avatarUrl string) *Identity {
 	return &Identity{
 		id: entity.UnsetId,
 		versions: []*Version{
 			{
 				name:      name,
 				email:     email,
-				login:     login,
 				avatarURL: avatarUrl,
 				nonce:     makeNonce(20),
 			},
@@ -271,8 +271,31 @@ func IsUserIdentitySet(repo repository.Repo) (bool, error) {
 	return len(configs) == 1, nil
 }
 
-func (i *Identity) AddVersion(version *Version) {
-	i.versions = append(i.versions, version)
+type Mutator struct {
+	Name      string
+	Email     string
+	AvatarUrl string
+	Keys      []*Key
+}
+
+// Mutate allow to create a new version of the Identity
+func (i *Identity) Mutate(f func(orig Mutator) Mutator) {
+	orig := Mutator{
+		Name:      i.Name(),
+		Email:     i.Email(),
+		AvatarUrl: i.AvatarUrl(),
+		Keys:      i.Keys(),
+	}
+	mutated := f(orig)
+	if reflect.DeepEqual(orig, mutated) {
+		return
+	}
+	i.versions = append(i.versions, &Version{
+		name:      mutated.Name,
+		email:     mutated.Email,
+		avatarURL: mutated.AvatarUrl,
+		keys:      mutated.Keys,
+	})
 }
 
 // Write the identity into the Repository. In particular, this ensure that
@@ -478,24 +501,19 @@ func (i *Identity) Email() string {
 	return i.lastVersion().email
 }
 
-// Login return the last version of the login
-func (i *Identity) Login() string {
-	return i.lastVersion().login
-}
-
 // AvatarUrl return the last version of the Avatar URL
 func (i *Identity) AvatarUrl() string {
 	return i.lastVersion().avatarURL
 }
 
 // Keys return the last version of the valid keys
-func (i *Identity) Keys() []Key {
+func (i *Identity) Keys() []*Key {
 	return i.lastVersion().keys
 }
 
 // ValidKeysAtTime return the set of keys valid at a given lamport time
-func (i *Identity) ValidKeysAtTime(time lamport.Time) []Key {
-	var result []Key
+func (i *Identity) ValidKeysAtTime(time lamport.Time) []*Key {
+	var result []*Key
 
 	for _, v := range i.versions {
 		if v.time > time {
@@ -511,16 +529,7 @@ func (i *Identity) ValidKeysAtTime(time lamport.Time) []Key {
 // DisplayName return a non-empty string to display, representing the
 // identity, based on the non-empty values.
 func (i *Identity) DisplayName() string {
-	switch {
-	case i.Name() == "" && i.Login() != "":
-		return i.Login()
-	case i.Name() != "" && i.Login() == "":
-		return i.Name()
-	case i.Name() != "" && i.Login() != "":
-		return fmt.Sprintf("%s (%s)", i.Name(), i.Login())
-	}
-
-	panic("invalid person data")
+	return i.Name()
 }
 
 // IsProtected return true if the chain of git commits started to be signed.
@@ -540,9 +549,13 @@ func (i *Identity) LastModification() timestamp.Timestamp {
 	return timestamp.Timestamp(i.lastVersion().unixTime)
 }
 
-// SetMetadata store arbitrary metadata along the last defined Version.
-// If the Version has been commit to git already, it won't be overwritten.
+// SetMetadata store arbitrary metadata along the last not-commit Version.
+// If the Version has been commit to git already, a new identical version is added and will need to be
+// commit.
 func (i *Identity) SetMetadata(key string, value string) {
+	if i.lastVersion().commitHash != "" {
+		i.versions = append(i.versions, i.lastVersion().Clone())
+	}
 	i.lastVersion().SetMetadata(key, value)
 }
 
@@ -575,3 +588,9 @@ func (i *Identity) MutableMetadata() map[string]string {
 
 	return metadata
 }
+
+// addVersionForTest add a new version to the identity
+// Only for testing !
+func (i *Identity) addVersionForTest(version *Version) {
+	i.versions = append(i.versions, version)
+}

identity/identity_actions_test.go πŸ”—

@@ -48,14 +48,14 @@ func TestPushPull(t *testing.T) {
 
 	// Update both
 
-	identity1.AddVersion(&Version{
+	identity1.addVersionForTest(&Version{
 		name:  "name1b",
 		email: "email1b",
 	})
 	err = identity1.Commit(repoA)
 	require.NoError(t, err)
 
-	identity2.AddVersion(&Version{
+	identity2.addVersionForTest(&Version{
 		name:  "name2b",
 		email: "email2b",
 	})
@@ -92,7 +92,7 @@ func TestPushPull(t *testing.T) {
 
 	// Concurrent update
 
-	identity1.AddVersion(&Version{
+	identity1.addVersionForTest(&Version{
 		name:  "name1c",
 		email: "email1c",
 	})
@@ -102,7 +102,7 @@ func TestPushPull(t *testing.T) {
 	identity1B, err := ReadLocal(repoB, identity1.Id())
 	require.NoError(t, err)
 
-	identity1B.AddVersion(&Version{
+	identity1B.addVersionForTest(&Version{
 		name:  "name1concurrent",
 		email: "email1concurrent",
 	})

identity/identity_stub.go πŸ”—

@@ -64,11 +64,11 @@ func (IdentityStub) AvatarUrl() string {
 	panic("identities needs to be properly loaded with identity.ReadLocal()")
 }
 
-func (IdentityStub) Keys() []Key {
+func (IdentityStub) Keys() []*Key {
 	panic("identities needs to be properly loaded with identity.ReadLocal()")
 }
 
-func (IdentityStub) ValidKeysAtTime(time lamport.Time) []Key {
+func (IdentityStub) ValidKeysAtTime(_ lamport.Time) []*Key {
 	panic("identities needs to be properly loaded with identity.ReadLocal()")
 }
 

identity/identity_test.go πŸ”—

@@ -44,7 +44,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				time:  100,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyA"},
 				},
 			},
@@ -52,7 +52,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				time:  200,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyB"},
 				},
 			},
@@ -60,7 +60,7 @@ func TestIdentityCommitLoad(t *testing.T) {
 				time:  201,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyC"},
 				},
 			},
@@ -79,20 +79,25 @@ func TestIdentityCommitLoad(t *testing.T) {
 
 	// add more version
 
-	identity.AddVersion(&Version{
+	identity.Mutate(func(orig Mutator) Mutator {
+
+		return orig
+	})
+
+	identity.addVersionForTest(&Version{
 		time:  201,
 		name:  "RenΓ© Descartes",
 		email: "rene.descartes@example.com",
-		keys: []Key{
+		keys: []*Key{
 			{PubKey: "pubkeyD"},
 		},
 	})
 
-	identity.AddVersion(&Version{
+	identity.addVersionForTest(&Version{
 		time:  300,
 		name:  "RenΓ© Descartes",
 		email: "rene.descartes@example.com",
-		keys: []Key{
+		keys: []*Key{
 			{PubKey: "pubkeyE"},
 		},
 	})
@@ -123,7 +128,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				time:  100,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyA"},
 				},
 			},
@@ -131,7 +136,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				time:  200,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyB"},
 				},
 			},
@@ -139,7 +144,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				time:  201,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyC"},
 				},
 			},
@@ -147,7 +152,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				time:  201,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyD"},
 				},
 			},
@@ -155,7 +160,7 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 				time:  300,
 				name:  "RenΓ© Descartes",
 				email: "rene.descartes@example.com",
-				keys: []Key{
+				keys: []*Key{
 					{PubKey: "pubkeyE"},
 				},
 			},
@@ -163,13 +168,13 @@ func TestIdentity_ValidKeysAtTime(t *testing.T) {
 	}
 
 	assert.Nil(t, identity.ValidKeysAtTime(10))
-	assert.Equal(t, identity.ValidKeysAtTime(100), []Key{{PubKey: "pubkeyA"}})
-	assert.Equal(t, identity.ValidKeysAtTime(140), []Key{{PubKey: "pubkeyA"}})
-	assert.Equal(t, identity.ValidKeysAtTime(200), []Key{{PubKey: "pubkeyB"}})
-	assert.Equal(t, identity.ValidKeysAtTime(201), []Key{{PubKey: "pubkeyD"}})
-	assert.Equal(t, identity.ValidKeysAtTime(202), []Key{{PubKey: "pubkeyD"}})
-	assert.Equal(t, identity.ValidKeysAtTime(300), []Key{{PubKey: "pubkeyE"}})
-	assert.Equal(t, identity.ValidKeysAtTime(3000), []Key{{PubKey: "pubkeyE"}})
+	assert.Equal(t, identity.ValidKeysAtTime(100), []*Key{{PubKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(140), []*Key{{PubKey: "pubkeyA"}})
+	assert.Equal(t, identity.ValidKeysAtTime(200), []*Key{{PubKey: "pubkeyB"}})
+	assert.Equal(t, identity.ValidKeysAtTime(201), []*Key{{PubKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(202), []*Key{{PubKey: "pubkeyD"}})
+	assert.Equal(t, identity.ValidKeysAtTime(300), []*Key{{PubKey: "pubkeyE"}})
+	assert.Equal(t, identity.ValidKeysAtTime(3000), []*Key{{PubKey: "pubkeyE"}})
 }
 
 // Test the immutable or mutable metadata search
@@ -189,7 +194,7 @@ func TestMetadata(t *testing.T) {
 	assertHasKeyValue(t, identity.MutableMetadata(), "key1", "value1")
 
 	// try override
-	identity.AddVersion(&Version{
+	identity.addVersionForTest(&Version{
 		name:  "RenΓ© Descartes",
 		email: "rene.descartes@example.com",
 	})

identity/interface.go πŸ”—

@@ -17,17 +17,14 @@ type Interface interface {
 	// Email return the last version of the email
 	Email() string
 
-	// Login return the last version of the login
-	Login() string
-
 	// AvatarUrl return the last version of the Avatar URL
 	AvatarUrl() string
 
 	// Keys return the last version of the valid keys
-	Keys() []Key
+	Keys() []*Key
 
 	// ValidKeysAtTime return the set of keys valid at a given lamport time
-	ValidKeysAtTime(time lamport.Time) []Key
+	ValidKeysAtTime(time lamport.Time) []*Key
 
 	// DisplayName return a non-empty string to display, representing the
 	// identity, based on the non-empty values.

identity/key.go πŸ”—

@@ -11,3 +11,8 @@ func (k *Key) Validate() error {
 
 	return nil
 }
+
+func (k *Key) Clone() *Key {
+	clone := *k
+	return &clone
+}

identity/version.go πŸ”—

@@ -24,14 +24,13 @@ type Version struct {
 	unixTime int64
 
 	name      string
-	email     string
-	login     string
+	email     string // as defined in git, not for bridges
 	avatarURL string
 
 	// The set of keys valid at that time, from this version onward, until they get removed
 	// in a new version. This allow to have multiple key for the same identity (e.g. one per
 	// device) as well as revoke key.
-	keys []Key
+	keys []*Key
 
 	// This optional array is here to ensure a better randomness of the identity id to avoid collisions.
 	// It has no functional purpose and should be ignored.
@@ -53,13 +52,28 @@ type VersionJSON struct {
 	UnixTime  int64             `json:"unix_time"`
 	Name      string            `json:"name,omitempty"`
 	Email     string            `json:"email,omitempty"`
-	Login     string            `json:"login,omitempty"`
 	AvatarUrl string            `json:"avatar_url,omitempty"`
-	Keys      []Key             `json:"pub_keys,omitempty"`
+	Keys      []*Key            `json:"pub_keys,omitempty"`
 	Nonce     []byte            `json:"nonce,omitempty"`
 	Metadata  map[string]string `json:"metadata,omitempty"`
 }
 
+// Make a deep copy
+func (v *Version) Clone() *Version {
+	clone := &Version{
+		name:      v.name,
+		email:     v.email,
+		avatarURL: v.avatarURL,
+		keys:      make([]*Key, len(v.keys)),
+	}
+
+	for i, key := range v.keys {
+		clone.keys[i] = key.Clone()
+	}
+
+	return clone
+}
+
 func (v *Version) MarshalJSON() ([]byte, error) {
 	return json.Marshal(VersionJSON{
 		FormatVersion: formatVersion,
@@ -67,7 +81,6 @@ func (v *Version) MarshalJSON() ([]byte, error) {
 		UnixTime:      v.unixTime,
 		Name:          v.name,
 		Email:         v.email,
-		Login:         v.login,
 		AvatarUrl:     v.avatarURL,
 		Keys:          v.keys,
 		Nonce:         v.nonce,
@@ -90,7 +103,6 @@ func (v *Version) UnmarshalJSON(data []byte) error {
 	v.unixTime = aux.UnixTime
 	v.name = aux.Name
 	v.email = aux.Email
-	v.login = aux.Login
 	v.avatarURL = aux.AvatarUrl
 	v.keys = aux.Keys
 	v.nonce = aux.Nonce
@@ -108,8 +120,8 @@ func (v *Version) Validate() error {
 		return fmt.Errorf("lamport time not set")
 	}
 
-	if text.Empty(v.name) && text.Empty(v.login) {
-		return fmt.Errorf("either name or login should be set")
+	if text.Empty(v.name) {
+		return fmt.Errorf("name not set")
 	}
 
 	if strings.Contains(v.name, "\n") {
@@ -120,14 +132,6 @@ func (v *Version) Validate() error {
 		return fmt.Errorf("name is not fully printable")
 	}
 
-	if strings.Contains(v.login, "\n") {
-		return fmt.Errorf("login should be a single line")
-	}
-
-	if !text.Safe(v.login) {
-		return fmt.Errorf("login is not fully printable")
-	}
-
 	if strings.Contains(v.email, "\n") {
 		return fmt.Errorf("email should be a single line")
 	}
@@ -202,7 +206,7 @@ func (v *Version) GetMetadata(key string) (string, bool) {
 	return val, ok
 }
 
-// AllMetadata return all metadata for this Identity
+// AllMetadata return all metadata for this Version
 func (v *Version) AllMetadata() map[string]string {
 	return v.metadata
 }

identity/version_test.go πŸ”—

@@ -9,11 +9,10 @@ import (
 
 func TestVersionSerialize(t *testing.T) {
 	before := &Version{
-		login:     "login",
 		name:      "name",
 		email:     "email",
 		avatarURL: "avatarUrl",
-		keys: []Key{
+		keys: []*Key{
 			{
 				Fingerprint: "fingerprint1",
 				PubKey:      "pubkey1",

input/prompt.go πŸ”—

@@ -4,23 +4,38 @@ import (
 	"bufio"
 	"fmt"
 	"os"
+	"strconv"
 	"strings"
+	"syscall"
+
+	"golang.org/x/crypto/ssh/terminal"
+
+	"github.com/MichaelMure/git-bug/util/interrupt"
 )
 
-func PromptValue(name string, preValue string) (string, error) {
-	return promptValue(name, preValue, false)
+// PromptValidator is a validator for a user entry
+// If complaint is "", value is considered valid, otherwise it's the error reported to the user
+// If err != nil, a terminal error happened
+type PromptValidator func(name string, value string) (complaint string, err error)
+
+// Required is a validator preventing a "" value
+func Required(name string, value string) (string, error) {
+	if value == "" {
+		return fmt.Sprintf("%s is empty", name), nil
+	}
+	return "", nil
 }
 
-func PromptValueRequired(name string, preValue string) (string, error) {
-	return promptValue(name, preValue, true)
+func Prompt(prompt, name string, validators ...PromptValidator) (string, error) {
+	return PromptDefault(prompt, name, "", validators...)
 }
 
-func promptValue(name string, preValue string, required bool) (string, error) {
+func PromptDefault(prompt, name, preValue string, validators ...PromptValidator) (string, error) {
 	for {
 		if preValue != "" {
-			_, _ = fmt.Fprintf(os.Stderr, "%s [%s]: ", name, preValue)
+			_, _ = fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, preValue)
 		} else {
-			_, _ = fmt.Fprintf(os.Stderr, "%s: ", name)
+			_, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt)
 		}
 
 		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
@@ -31,14 +46,85 @@ func promptValue(name string, preValue string, required bool) (string, error) {
 		line = strings.TrimSpace(line)
 
 		if preValue != "" && line == "" {
-			return preValue, nil
+			line = preValue
 		}
 
-		if required && line == "" {
-			_, _ = fmt.Fprintf(os.Stderr, "%s is empty\n", name)
-			continue
+		for _, validator := range validators {
+			complaint, err := validator(name, line)
+			if err != nil {
+				return "", err
+			}
+			if complaint != "" {
+				_, _ = fmt.Fprintln(os.Stderr, complaint)
+				continue
+			}
 		}
 
 		return line, nil
 	}
 }
+
+func PromptPassword(prompt, name string, validators ...PromptValidator) (string, error) {
+	termState, err := terminal.GetState(syscall.Stdin)
+	if err != nil {
+		return "", err
+	}
+
+	cancel := interrupt.RegisterCleaner(func() error {
+		return terminal.Restore(syscall.Stdin, termState)
+	})
+	defer cancel()
+
+	for {
+		_, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt)
+
+		bytePassword, err := terminal.ReadPassword(syscall.Stdin)
+		// new line for coherent formatting, ReadPassword clip the normal new line
+		// entered by the user
+		fmt.Println()
+
+		if err != nil {
+			return "", err
+		}
+
+		pass := string(bytePassword)
+
+		for _, validator := range validators {
+			complaint, err := validator(name, pass)
+			if err != nil {
+				return "", err
+			}
+			if complaint != "" {
+				_, _ = fmt.Fprintln(os.Stderr, complaint)
+				continue
+			}
+		}
+
+		return pass, nil
+	}
+}
+
+func PromptChoice(prompt string, choices []string) (int, error) {
+	for {
+		for i, choice := range choices {
+			_, _ = fmt.Fprintf(os.Stderr, "[%d]: %s\n", i+1, choice)
+		}
+		_, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt)
+
+		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+		fmt.Println()
+		if err != nil {
+			return 0, err
+		}
+
+		line = strings.TrimSpace(line)
+
+		index, err := strconv.Atoi(line)
+		if err != nil || index < 1 || index > len(choices) {
+			fmt.Println("invalid input")
+			continue
+		}
+
+		return index, nil
+	}
+}

misc/bash_completion/git-bug πŸ”—

@@ -305,6 +305,14 @@ _git-bug_bridge_auth_add-token()
     two_word_flags+=("--target")
     two_word_flags+=("-t")
     local_nonpersistent_flags+=("--target=")
+    flags+=("--login=")
+    two_word_flags+=("--login")
+    two_word_flags+=("-l")
+    local_nonpersistent_flags+=("--login=")
+    flags+=("--user=")
+    two_word_flags+=("--user")
+    two_word_flags+=("-u")
+    local_nonpersistent_flags+=("--user=")
 
     must_have_one_flag=()
     must_have_one_noun=()

misc/powershell_completion/git-bug πŸ”—

@@ -64,6 +64,10 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock {
         'git-bug;bridge;auth;add-token' {
             [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('-l', 'l', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
+            [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login in the remote bug-tracker')
+            [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
+            [CompletionResult]::new('--user', 'user', [CompletionResultType]::ParameterName, 'The user to add the token to. Default is the current user')
             break
         }
         'git-bug;bridge;auth;rm' {

misc/zsh_completion/git-bug πŸ”—

@@ -177,7 +177,9 @@ function _git-bug_bridge_auth {
 
 function _git-bug_bridge_auth_add-token {
   _arguments \
-    '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,launchpad-preview]]:'
+    '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,launchpad-preview]]:' \
+    '(-l --login)'{-l,--login}'[The login in the remote bug-tracker]:' \
+    '(-u --user)'{-u,--user}'[The user to add the token to. Default is the current user]:'
 }
 
 function _git-bug_bridge_auth_rm {