diff options
author | Michael Muré <batolettre@gmail.com> | 2020-02-09 20:08:12 +0100 |
---|---|---|
committer | Michael Muré <batolettre@gmail.com> | 2020-02-09 20:23:38 +0100 |
commit | b3d3612393387c83fa43f908dbb8e2a71068c834 (patch) | |
tree | e4609e21dc74e535d45b38cd7d0504681c544160 /bridge | |
parent | dca85b309a0a82e9993a345964d0831ab2876fb4 (diff) | |
parent | 3caffeef4d2ed25d4eb5d4bfd262f4fc3b92561f (diff) | |
download | git-bug-b3d3612393387c83fa43f908dbb8e2a71068c834.tar.gz |
Merge remote-tracking branch 'origin/master' into cheshirekow-jira
Diffstat (limited to 'bridge')
31 files changed, 542 insertions, 435 deletions
diff --git a/bridge/bridges.go b/bridge/bridges.go index 84ca9abe..d74a58fa 100644 --- a/bridge/bridges.go +++ b/bridge/bridges.go @@ -23,6 +23,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) diff --git a/bridge/core/auth/credential.go b/bridge/core/auth/credential.go index a462a116..c1255aa6 100644 --- a/bridge/core/auth/credential.go +++ b/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 @@ -34,15 +36,18 @@ func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatc type Credential interface { ID() entity.Id - UserId() 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. - ToConfig() map[string]string + // This does not include Target, Kind, CreateTime and Metadata. + toConfig() map[string]string } // Load loads a credential from the repo config @@ -90,6 +95,7 @@ func LoadWithPrefix(repo repository.RepoConfig, prefix string) (Credential, erro return matching[0], nil } +// loadFromConfig is a helper to construct a Credential from the set of git configs func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, error) { keyPrefix := fmt.Sprintf("%s.%s.", configKeyPrefix, id) @@ -113,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 + ".") @@ -120,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) } @@ -168,7 +188,7 @@ func PrefixExist(repo repository.RepoConfig, prefix string) bool { // Store stores a credential in the global git config func Store(repo repository.RepoConfig, cred Credential) error { - confs := cred.ToConfig() + confs := cred.toConfig() prefix := fmt.Sprintf("%s.%s.", configKeyPrefix, cred.ID()) @@ -178,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 { @@ -196,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) diff --git a/bridge/core/auth/credential_test.go b/bridge/core/auth/credential_test.go index f91d273d..2f8806c9 100644 --- a/bridge/core/auth/credential_test.go +++ b/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) diff --git a/bridge/core/auth/options.go b/bridge/core/auth/options.go index 7bcda68e..74189874 100644 --- a/bridge/core/auth/options.go +++ b/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 } } diff --git a/bridge/core/auth/token.go b/bridge/core/auth/token.go index 12a3bfc0..42f960bf 100644 --- a/bridge/core/auth/token.go +++ b/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,10 +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) Target() string { return t.target } @@ -88,7 +83,23 @@ func (t *Token) Validate() error { return nil } -func (t *Token) ToConfig() map[string]string { +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, } diff --git a/bridge/core/bridge.go b/bridge/core/bridge.go index 95c0c2c4..ac0d47d7 100644 --- a/bridge/core/bridge.go +++ b/bridge/core/bridge.go @@ -28,28 +28,31 @@ 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 // implementation with utility code to provide high-level functions. type Bridge struct { - Name string - repo *cache.RepoCache - impl BridgeImpl - importer Importer - exporter Exporter - conf Configuration - initDone bool + Name string + repo *cache.RepoCache + impl BridgeImpl + importer Importer + exporter Exporter + conf Configuration + initImportDone bool + initExportDone bool } // Register will register a new BridgeImpl @@ -57,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 @@ -79,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] @@ -273,8 +292,25 @@ func (b *Bridge) getExporter() Exporter { return b.exporter } -func (b *Bridge) ensureInit() error { - if b.initDone { +func (b *Bridge) ensureImportInit() error { + if b.initImportDone { + return nil + } + + importer := b.getImporter() + if importer != nil { + err := importer.Init(b.repo, b.conf) + if err != nil { + return err + } + } + + b.initImportDone = true + return nil +} + +func (b *Bridge) ensureExportInit() error { + if b.initExportDone { return nil } @@ -294,8 +330,7 @@ func (b *Bridge) ensureInit() error { } } - b.initDone = true - + b.initExportDone = true return nil } @@ -313,7 +348,7 @@ func (b *Bridge) ImportAllSince(ctx context.Context, since time.Time) (<-chan Im return nil, err } - err = b.ensureInit() + err = b.ensureImportInit() if err != nil { return nil, err } @@ -367,7 +402,7 @@ func (b *Bridge) ExportAll(ctx context.Context, since time.Time) (<-chan ExportR return nil, err } - err = b.ensureInit() + err = b.ensureExportInit() if err != nil { return nil, err } diff --git a/bridge/core/config.go b/bridge/core/config.go new file mode 100644 index 00000000..afcda560 --- /dev/null +++ b/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 +} diff --git a/bridge/core/export.go b/bridge/core/export.go index ef7a2e57..fa531c5f 100644 --- a/bridge/core/export.go +++ b/bridge/core/export.go @@ -27,9 +27,12 @@ const ( // Nothing changed on the bug ExportEventNothing + // Something wrong happened during export that is worth notifying to the user + // but not severe enough to consider the export a failure. + ExportEventWarning + // Error happened during export ExportEventError - ExportEventWarning ) // ExportResult is an event that is emitted during the export process, to diff --git a/bridge/core/import.go b/bridge/core/import.go index 3b1f3ac3..0b0b4c68 100644 --- a/bridge/core/import.go +++ b/bridge/core/import.go @@ -30,9 +30,12 @@ const ( // Identity has been created ImportEventIdentity + // Something wrong happened during import that is worth notifying to the user + // but not severe enough to consider the import a failure. + ImportEventWarning + // Error happened during import ImportEventError - ImportEventWarning ) // ImportResult is an event that is emitted during the import process, to diff --git a/bridge/core/interfaces.go b/bridge/core/interfaces.go index 77e0a7b9..ab2f3977 100644 --- a/bridge/core/interfaces.go +++ b/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) diff --git a/bridge/github/config.go b/bridge/github/config.go index bc26a2fc..9477801d 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -14,29 +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/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 ( @@ -50,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 @@ -88,9 +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 { - 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 + } + + login, err = input.Prompt("Github login", "login", input.Required, validator) + if err != nil { + return nil, err + } } var cred auth.Credential @@ -101,13 +97,11 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor if err != nil { return nil, err } - if cred.UserId() != user.Id() { - return nil, fmt.Errorf("selected credential don't match the user") - } case params.TokenRaw != "": - cred = auth.NewToken(user.Id(), params.TokenRaw, target) + cred = auth.NewToken(params.TokenRaw, target) + cred.SetMetadata(auth.MetaKeyLogin, login) default: - cred, err = promptTokenOptions(repo, user.Id(), owner, project) + cred, err = promptTokenOptions(repo, login, owner, project) if err != nil { return nil, err } @@ -144,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 { @@ -165,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"` @@ -191,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 != "" { @@ -235,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 } @@ -253,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), ) } @@ -284,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 } @@ -308,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 + validator := func(name string, value string) (complaint string, err error) { + if re.MatchString(value) { + return "", nil } - - token := strings.TrimSpace(line) - if re.MatchString(token) { - return token, nil - } - - fmt.Println("token is invalid") + 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.") @@ -341,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() - if err != nil { - return "", err - } - - username, err := promptUsername() + i, err := input.PromptChoice("repository visibility", []string{"public", "private"}) if err != nil { return "", err } + isPublic := i == 0 - password, err := promptPassword() + password, err := input.PromptPassword("Password", "password", input.Required) if err != nil { return "", err } @@ -370,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 } @@ -380,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 } @@ -401,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() @@ -578,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 - } -} diff --git a/bridge/github/config_test.go b/bridge/github/config_test.go index 9798d26b..d7b1b38d 100644 --- a/bridge/github/config_test.go +++ b/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 diff --git a/bridge/github/export.go b/bridge/github/export.go index 6c089a47..c363e188 100644 --- a/bridge/github/export.go +++ b/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 diff --git a/bridge/github/export_test.go b/bridge/github/export_test.go index 5a0bc653..7d6e6fb1 100644 --- a/bridge/github/export_test.go +++ b/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) diff --git a/bridge/github/github.go b/bridge/github/github.go index 874c2d11..19dc8a08 100644 --- a/bridge/github/github.go +++ b/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{} } diff --git a/bridge/github/import.go b/bridge/github/import.go index 092e3e71..ea0ccba3 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -15,12 +15,6 @@ import ( "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 @@ -38,17 +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())) - } - - creds, err := auth.List(repo, opts...) + creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken)) if err != nil { return err } @@ -197,6 +181,11 @@ func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline // other edits will be added as CommentEdit operations target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(issue.Id)) + if err == cache.ErrNoMatchingOp { + // original comment is missing somehow, issuing a warning + gi.out <- core.NewImportWarning(fmt.Errorf("comment ID %s to edit is missing", parseId(issue.Id)), b.Id()) + continue + } if err != nil { return nil, err } @@ -545,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), @@ -595,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), diff --git a/bridge/github/import_test.go b/bridge/github/import_test.go index 304229a0..a8f8e346 100644 --- a/bridge/github/import_test.go +++ b/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,10 +141,11 @@ func Test_Importer(t *testing.T) { t.Skip("Env var GITHUB_TOKEN_PRIVATE missing") } - err = author.Commit(repo) - 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) diff --git a/bridge/gitlab/config.go b/bridge/gitlab/config.go index 99c27836..fb593819 100644 --- a/bridge/gitlab/config.go +++ b/bridge/gitlab/config.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "path" "regexp" "sort" "strconv" @@ -18,7 +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/input" "github.com/MichaelMure/git-bug/repository" "github.com/MichaelMure/git-bug/util/colors" ) @@ -34,16 +35,22 @@ 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 + var baseUrl string - if (params.CredPrefix != "" || params.TokenRaw != "") && params.URL == "" { - return nil, fmt.Errorf("you must provide a project URL to configure this bridge with a token") - } - - if params.URL == "" { - params.URL = defaultBaseURL + switch { + case params.BaseURL != "": + baseUrl = params.BaseURL + default: + baseUrl, err = promptBaseUrlOptions() + if err != nil { + return nil, errors.Wrap(err, "base url prompt") + } } var url string @@ -54,7 +61,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor url = params.URL default: // terminal prompt - url, err = promptURL(repo) + url, err = promptURL(repo, baseUrl) if err != nil { return nil, errors.Wrap(err, "url prompt") } @@ -64,11 +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 { - return nil, err - } - var cred auth.Credential switch { @@ -77,13 +79,16 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor if err != nil { return nil, err } - if cred.UserId() != user.Id() { - return nil, fmt.Errorf("selected credential don't match the user") - } case params.TokenRaw != "": - cred = auth.NewToken(user.Id(), 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, user.Id()) + cred, err = promptTokenOptions(repo, baseUrl) if err != nil { return nil, err } @@ -95,14 +100,14 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor } // validate project url and get its ID - id, err := validateProjectURL(params.BaseURL, url, token) + id, err := validateProjectURL(baseUrl, url, token) if err != nil { return nil, errors.Wrap(err, "project validation") } conf[core.ConfigKeyTarget] = target conf[keyProjectID] = strconv.Itoa(id) - conf[keyGitlabBaseUrl] = params.BaseURL + conf[keyGitlabBaseUrl] = baseUrl err = g.ValidateConfig(conf) if err != nil { @@ -126,7 +131,9 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error { } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } - + if _, ok := conf[keyGitlabBaseUrl]; !ok { + return fmt.Errorf("missing %s key", keyGitlabBaseUrl) + } if _, ok := conf[keyProjectID]; !ok { return fmt.Errorf("missing %s key", keyProjectID) } @@ -134,20 +141,51 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error { return nil } -func promptTokenOptions(repo repository.RepoConfig, userId entity.Id) (auth.Credential, error) { +func promptBaseUrlOptions() (string, error) { + index, err := input.PromptChoice("Gitlab base url", []string{ + "https://gitlab.com", + "enter your own base url", + }) + + if err != nil { + return "", err + } + + if index == 0 { + return defaultBaseURL, nil + } else { + return promptBaseUrl() + } +} + +func promptBaseUrl() (string, error) { + validator := func(name string, value string) (string, error) { + u, err := url.Parse(value) + if err != nil { + return err.Error(), nil + } + if u.Scheme == "" { + return "missing scheme", nil + } + if u.Host == "" { + return "missing host", nil + } + return "", nil + } + + return input.Prompt("Base url", "url", input.Required, validator) +} + +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() - if err != nil { - return nil, err - } - return auth.NewToken(userId, value, target), nil + return promptToken(baseUrl) } fmt.Println() @@ -185,54 +223,57 @@ func promptTokenOptions(repo repository.RepoConfig, userId entity.Id) (auth.Cred switch index { case 1: - value, err := promptToken() - if err != nil { - return nil, err - } - return auth.NewToken(userId, value, target), nil + return promptToken(baseUrl) default: return creds[index-2], nil } } } -func promptToken() (string, error) { - fmt.Println("You can generate a new token by visiting https://gitlab.com/profile/personal_access_tokens.") +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 format is invalid") + 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) (string, error) { +func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) { // remote suggestions remotes, err := repo.GetRemotes() if err != nil { return "", errors.Wrap(err, "getting remotes") } - validRemotes := getValidGitlabRemoteURLs(remotes) + validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes) if len(validRemotes) > 0 { for { fmt.Println("\nDetected projects:") @@ -286,7 +327,7 @@ func promptURL(repo repository.RepoCommon) (string, error) { } } -func getProjectPath(projectUrl string) (string, error) { +func getProjectPath(baseUrl, projectUrl string) (string, error) { cleanUrl := strings.TrimSuffix(projectUrl, ".git") cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1) objectUrl, err := url.Parse(cleanUrl) @@ -294,38 +335,63 @@ func getProjectPath(projectUrl string) (string, error) { return "", ErrBadProjectURL } + objectBaseUrl, err := url.Parse(baseUrl) + if err != nil { + return "", ErrBadProjectURL + } + + if objectUrl.Hostname() != objectBaseUrl.Hostname() { + return "", fmt.Errorf("base url and project url hostnames doesn't match") + } return objectUrl.Path[1:], nil } -func getValidGitlabRemoteURLs(remotes map[string]string) []string { +func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string { urls := make([]string, 0, len(remotes)) for _, u := range remotes { - path, err := getProjectPath(u) + path, err := getProjectPath(baseUrl, u) if err != nil { continue } - urls = append(urls, fmt.Sprintf("%s%s", "gitlab.com", path)) + urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path)) } return urls } -func validateProjectURL(baseURL, url string, token *auth.Token) (int, error) { - projectPath, err := getProjectPath(url) +func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) { + projectPath, err := getProjectPath(baseUrl, url) if err != nil { return 0, err } - client, err := buildClient(baseURL, token) + client, err := buildClient(baseUrl, token) if err != nil { return 0, err } project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{}) if err != nil { - return 0, err + 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 +} diff --git a/bridge/gitlab/config_test.go b/bridge/gitlab/config_test.go index 87469796..43ed649a 100644 --- a/bridge/gitlab/config_test.go +++ b/bridge/gitlab/config_test.go @@ -82,7 +82,7 @@ func TestProjectPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, err := getProjectPath(tt.args.url) + path, err := getProjectPath(defaultBaseURL, tt.args.url) assert.Equal(t, tt.want.path, path) assert.Equal(t, tt.want.err, err) }) diff --git a/bridge/gitlab/export.go b/bridge/gitlab/export.go index d42ef1cd..c5323da4 100644 --- a/bridge/gitlab/export.go +++ b/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 } } @@ -159,7 +173,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc gitlabID, ok := snapshot.GetCreateMetadata(metaKeyGitlabId) if ok { gitlabBaseUrl, ok := snapshot.GetCreateMetadata(metaKeyGitlabBaseUrl) - if ok && gitlabBaseUrl != ge.conf[gitlabBaseUrl] { + if ok && gitlabBaseUrl != ge.conf[keyGitlabBaseUrl] { out <- core.NewExportNothing(b.Id(), "skipping issue imported from another Gitlab instance") return } diff --git a/bridge/gitlab/export_test.go b/bridge/gitlab/export_test.go index d16defd0..1d387655 100644 --- a/bridge/gitlab/export_test.go +++ b/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) diff --git a/bridge/gitlab/gitlab.go b/bridge/gitlab/gitlab.go index 9298dc8e..8512379c 100644 --- a/bridge/gitlab/gitlab.go +++ b/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{} } diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go index 4fa37505..d699554b 100644 --- a/bridge/gitlab/import.go +++ b/bridge/gitlab/import.go @@ -33,17 +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())) - } - - creds, err := auth.List(repo, opts...) + creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken)) if err != nil { return err } @@ -399,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 diff --git a/bridge/gitlab/import_test.go b/bridge/gitlab/import_test.go index 6e378b07..3c0caa55 100644 --- a/bridge/gitlab/import_test.go +++ b/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,10 +95,11 @@ func TestImport(t *testing.T) { t.Skip("Env var GITLAB_PROJECT_ID missing") } - err = author.Commit(repo) - 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) diff --git a/bridge/jira/client.go b/bridge/jira/client.go index 040c988e..6ec1c9dd 100644 --- a/bridge/jira/client.go +++ b/bridge/jira/client.go @@ -386,7 +386,7 @@ func (client *Client) Login(conf core.Configuration) error { password := conf[keyPassword] if password == "" { var err error - password, err = input.PromptPassword() + password, err = input.PromptPassword("Password", "password", input.Required) if err != nil { return err } diff --git a/bridge/jira/config.go b/bridge/jira/config.go index 59076564..406bed31 100644 --- a/bridge/jira/config.go +++ b/bridge/jira/config.go @@ -8,7 +8,6 @@ import ( "os" "strconv" "strings" - "time" "github.com/pkg/errors" @@ -17,22 +16,6 @@ import ( "github.com/MichaelMure/git-bug/input" ) -const ( - target = "jira" - keyServer = "server" - keyProject = "project" - keyCredentialsType = "credentials-type" - keyCredentialsFile = "credentials-file" - keyUsername = "username" - keyPassword = "password" - keyIDMap = "bug-id-map" - keyIDRevMap = "bug-id-revmap" - keyCreateDefaults = "create-issue-defaults" - keyCreateGitBug = "create-issue-gitbug-id" - - defaultTimeout = 60 * time.Second -) - const moreConfigText = ` NOTE: There are a few optional configuration values that you can additionally set in your git configuration to influence the behavior of the bridge. Please @@ -65,9 +48,7 @@ How would you like to store your JIRA login credentials? ` // Configure sets up the bridge configuration -func (g *Jira) Configure( - repo *cache.RepoCache, params core.BridgeParams) ( - core.Configuration, error) { +func (g *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { conf := make(core.Configuration) var err error var url string @@ -126,7 +107,7 @@ func (g *Jira) Configure( return nil, err } - password, err = input.PromptPassword() + password, err = input.PromptPassword("Password", "password", input.Required) if err != nil { return nil, err } diff --git a/bridge/jira/import.go b/bridge/jira/import.go index 2337d8bd..bc1bf428 100644 --- a/bridge/jira/import.go +++ b/bridge/jira/import.go @@ -175,7 +175,6 @@ func (self *jiraImporter) ensurePerson( user.DisplayName, user.EmailAddress, user.Key, - "", map[string]string{ keyJiraUser: string(user.Key), }, diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go index accb9e7c..43a11c05 100644 --- a/bridge/jira/jira.go +++ b/bridge/jira/jira.go @@ -3,10 +3,32 @@ package jira import ( "sort" + "time" "github.com/MichaelMure/git-bug/bridge/core" ) +const ( + target = "jira" + + metaKeyJiraLogin = "jira-login" + + keyServer = "server" + keyProject = "project" + keyCredentialsType = "credentials-type" + keyCredentialsFile = "credentials-file" + keyUsername = "username" + keyPassword = "password" + keyIDMap = "bug-id-map" + keyIDRevMap = "bug-id-revmap" + keyCreateDefaults = "create-issue-defaults" + keyCreateGitBug = "create-issue-gitbug-id" + + defaultTimeout = 60 * time.Second +) + +var _ core.BridgeImpl = &Jira{} + // Jira Main object for the bridge type Jira struct{} @@ -15,6 +37,10 @@ func (*Jira) Target() string { return target } +func (*Jira) LoginMetaKey() string { + return metaKeyJiraLogin +} + // NewImporter returns the jira importer func (*Jira) NewImporter() core.Importer { return &jiraImporter{} @@ -39,8 +65,7 @@ func stringInSlice(needle string, haystack []string) bool { // 1. elements found only in the first input list // 2. elements found only in the second input list // 3. elements found in both input lists -func setSymmetricDifference( - setA, setB []string) ([]string, []string, []string) { +func setSymmetricDifference(setA, setB []string) ([]string, []string, []string) { sort.Strings(setA) sort.Strings(setB) diff --git a/bridge/launchpad/config.go b/bridge/launchpad/config.go index edbd941d..e029fad3 100644 --- a/bridge/launchpad/config.go +++ b/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) diff --git a/bridge/launchpad/import.go b/bridge/launchpad/import.go index 619631b3..5bca8e63 100644 --- a/bridge/launchpad/import.go +++ b/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, diff --git a/bridge/launchpad/launchpad.go b/bridge/launchpad/launchpad.go index 030d9169..b4fcdd00 100644 --- a/bridge/launchpad/launchpad.go +++ b/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{} } |