diff options
90 files changed, 4515 insertions, 826 deletions
@@ -122,32 +122,32 @@ The web UI interact with the backend through a GraphQL API. The schema is availa ### Importer implementations -| | Github | Gitlab | Launchpad | -| --- | --- | --- | --- | -| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :heavy_check_mark: | :x: | -| **with resume**<br/>(download only new data) | :heavy_check_mark: | :heavy_check_mark: | :x: | -| **identities** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| identities update | :x: | :x: | :x: | -| **bug** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| comments | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| comment editions | :heavy_check_mark: | :x: | :x: | -| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | -| status | :heavy_check_mark: | :heavy_check_mark: | :x: | -| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | -| **media/files** | :x: | :x: | :x: | -| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | +| | Github | Gitlab | Launchpad | Jira | +| --- | --- | --- | --- | --- | +| **incremental**<br/>(can import more than once) | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| **with resume**<br/>(download only new data) | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| **identities** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| identities update | :x: | :x: | :x: | :heavy_check_mark: | +| **bug** | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| comments | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| comment editions | :heavy_check_mark: | :x: | :x: | :heavy_check_mark: | +| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| status | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| **media/files** | :x: | :x: | :x: | :x: | +| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: | ### Exporter implementations -| | Github | Gitlab | Launchpad | -| --- | --- | --- | --- | -| **bug** | :heavy_check_mark: | :heavy_check_mark: | :x: | -| comments | :heavy_check_mark: | :heavy_check_mark: | :x: | -| comment editions | :heavy_check_mark: | :heavy_check_mark: | :x: | -| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | -| status | :heavy_check_mark: | :heavy_check_mark: | :x: | -| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | -| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | +| | Github | Gitlab | Launchpad | Jira | +| --- | --- | --- | --- | --- | +| **bug** | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| comments | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| comment editions | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| labels | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| status | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| title edition | :heavy_check_mark: | :heavy_check_mark: | :x: | :heavy_check_mark: | +| **automated test suite** | :heavy_check_mark: | :heavy_check_mark: | :x: | :x: | #### Bridge usage diff --git a/bridge/bridges.go b/bridge/bridges.go index 5d3066f9..d74a58fa 100644 --- a/bridge/bridges.go +++ b/bridge/bridges.go @@ -5,6 +5,7 @@ import ( "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bridge/github" "github.com/MichaelMure/git-bug/bridge/gitlab" + "github.com/MichaelMure/git-bug/bridge/jira" "github.com/MichaelMure/git-bug/bridge/launchpad" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/repository" @@ -14,6 +15,7 @@ func init() { core.Register(&github.Github{}) core.Register(&gitlab.Gitlab{}) core.Register(&launchpad.Launchpad{}) + core.Register(&jira.Jira{}) } // Targets return all known bridge implementation target diff --git a/bridge/core/auth/credential.go b/bridge/core/auth/credential.go index 6dcac09f..d95b23c7 100644 --- a/bridge/core/auth/credential.go +++ b/bridge/core/auth/credential.go @@ -1,6 +1,8 @@ package auth import ( + "crypto/rand" + "encoding/base64" "errors" "fmt" "regexp" @@ -16,6 +18,7 @@ const ( configKeyKind = "kind" configKeyTarget = "target" configKeyCreateTime = "createtime" + configKeySalt = "salt" configKeyPrefixMeta = "meta." MetaKeyLogin = "login" @@ -26,6 +29,7 @@ type CredentialKind string const ( KindToken CredentialKind = "token" + KindLogin CredentialKind = "login" KindLoginPassword CredentialKind = "login-password" ) @@ -37,9 +41,10 @@ func NewErrMultipleMatchCredential(matching []entity.Id) *entity.ErrMultipleMatc type Credential interface { ID() entity.Id - Target() string Kind() CredentialKind + Target() string CreateTime() time.Time + Salt() []byte Validate() error Metadata() map[string]string @@ -47,7 +52,7 @@ type Credential interface { 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, Kind, CreateTime and Metadata. + // This does not include Target, Kind, CreateTime, Metadata or Salt. toConfig() map[string]string } @@ -108,13 +113,21 @@ func loadFromConfig(rawConfigs map[string]string, id entity.Id) (Credential, err } var cred Credential + var err error switch CredentialKind(configs[configKeyKind]) { case KindToken: - cred = NewTokenFromConfig(configs) + cred, err = NewTokenFromConfig(configs) + case KindLogin: + cred, err = NewLoginFromConfig(configs) case KindLoginPassword: + cred, err = NewLoginPasswordFromConfig(configs) default: - return nil, fmt.Errorf("unknown credential type %s", configs[configKeyKind]) + return nil, fmt.Errorf("unknown credential type \"%s\"", configs[configKeyKind]) + } + + if err != nil { + return nil, fmt.Errorf("loading credential: %v", err) } return cred, nil @@ -134,6 +147,23 @@ func metaFromConfig(configs map[string]string) map[string]string { return result } +func makeSalt() []byte { + result := make([]byte, 16) + _, err := rand.Read(result) + if err != nil { + panic(err) + } + return result +} + +func saltFromConfig(configs map[string]string) ([]byte, error) { + val, ok := configs[configKeySalt] + if !ok { + return nil, fmt.Errorf("no credential salt found") + } + return base64.StdEncoding.DecodeString(val) +} + // List load all existing credentials func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) { rawConfigs, err := repo.GlobalConfig().ReadAll(configKeyPrefix + ".") @@ -141,10 +171,7 @@ func List(repo repository.RepoConfig, opts ...Option) ([]Credential, error) { return nil, err } - re, err := regexp.Compile(`^` + configKeyPrefix + `\.([^.]+)\.([^.]+(?:\.[^.]+)*)$`) - if err != nil { - panic(err) - } + re := regexp.MustCompile(`^` + configKeyPrefix + `\.([^.]+)\.([^.]+(?:\.[^.]+)*)$`) mapped := make(map[string]map[string]string) @@ -211,6 +238,16 @@ func Store(repo repository.RepoConfig, cred Credential) error { return err } + // Salt + if len(cred.Salt()) != 16 { + panic("credentials need to be salted") + } + encoded := base64.StdEncoding.EncodeToString(cred.Salt()) + err = repo.GlobalConfig().StoreString(prefix+configKeySalt, encoded) + if err != nil { + return err + } + // Metadata for key, val := range cred.Metadata() { err := repo.GlobalConfig().StoreString(prefix+configKeyPrefixMeta+key, val) diff --git a/bridge/core/auth/credential_base.go b/bridge/core/auth/credential_base.go new file mode 100644 index 00000000..488c223c --- /dev/null +++ b/bridge/core/auth/credential_base.go @@ -0,0 +1,90 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/repository" +) + +type credentialBase struct { + target string + createTime time.Time + salt []byte + meta map[string]string +} + +func newCredentialBase(target string) *credentialBase { + return &credentialBase{ + target: target, + createTime: time.Now(), + salt: makeSalt(), + } +} + +func newCredentialBaseFromConfig(conf map[string]string) (*credentialBase, error) { + base := &credentialBase{ + target: conf[configKeyTarget], + meta: metaFromConfig(conf), + } + + if createTime, ok := conf[configKeyCreateTime]; ok { + t, err := repository.ParseTimestamp(createTime) + if err != nil { + return nil, err + } + base.createTime = t + } else { + return nil, fmt.Errorf("missing create time") + } + + salt, err := saltFromConfig(conf) + if err != nil { + return nil, err + } + base.salt = salt + + return base, nil +} + +func (cb *credentialBase) Target() string { + return cb.target +} + +func (cb *credentialBase) CreateTime() time.Time { + return cb.createTime +} + +func (cb *credentialBase) Salt() []byte { + return cb.salt +} + +func (cb *credentialBase) validate() error { + if cb.target == "" { + return fmt.Errorf("missing target") + } + if cb.createTime.IsZero() || cb.createTime.Equal(time.Time{}) { + return fmt.Errorf("missing creation time") + } + if !core.TargetExist(cb.target) { + return fmt.Errorf("unknown target") + } + return nil +} + +func (cb *credentialBase) Metadata() map[string]string { + return cb.meta +} + +func (cb *credentialBase) GetMetadata(key string) (string, bool) { + val, ok := cb.meta[key] + return val, ok +} + +func (cb *credentialBase) SetMetadata(key string, value string) { + if cb.meta == nil { + cb.meta = make(map[string]string) + } + cb.meta[key] = value +} diff --git a/bridge/core/auth/credential_test.go b/bridge/core/auth/credential_test.go index 2f8806c9..60c631d7 100644 --- a/bridge/core/auth/credential_test.go +++ b/bridge/core/auth/credential_test.go @@ -14,7 +14,7 @@ func TestCredential(t *testing.T) { repo := repository.NewMockRepoForTest() storeToken := func(val string, target string) *Token { - token := NewToken(val, target) + token := NewToken(target, val) err := Store(repo, token) require.NoError(t, err) return token @@ -100,3 +100,25 @@ func sameIds(t *testing.T, a []Credential, b []Credential) { assert.ElementsMatch(t, ids(a), ids(b)) } + +func testCredentialSerial(t *testing.T, original Credential) Credential { + repo := repository.NewMockRepoForTest() + + original.SetMetadata("test", "value") + + assert.NotEmpty(t, original.ID().String()) + assert.NotEmpty(t, original.Salt()) + assert.NoError(t, Store(repo, original)) + + loaded, err := LoadWithId(repo, original.ID()) + assert.NoError(t, err) + + assert.Equal(t, original.ID(), loaded.ID()) + assert.Equal(t, original.Kind(), loaded.Kind()) + assert.Equal(t, original.Target(), loaded.Target()) + assert.Equal(t, original.CreateTime().Unix(), loaded.CreateTime().Unix()) + assert.Equal(t, original.Salt(), loaded.Salt()) + assert.Equal(t, original.Metadata(), loaded.Metadata()) + + return loaded +} diff --git a/bridge/core/auth/login.go b/bridge/core/auth/login.go new file mode 100644 index 00000000..ea74835a --- /dev/null +++ b/bridge/core/auth/login.go @@ -0,0 +1,67 @@ +package auth + +import ( + "crypto/sha256" + "fmt" + + "github.com/MichaelMure/git-bug/entity" +) + +const ( + configKeyLoginLogin = "login" +) + +var _ Credential = &Login{} + +type Login struct { + *credentialBase + Login string +} + +func NewLogin(target, login string) *Login { + return &Login{ + credentialBase: newCredentialBase(target), + Login: login, + } +} + +func NewLoginFromConfig(conf map[string]string) (*Login, error) { + base, err := newCredentialBaseFromConfig(conf) + if err != nil { + return nil, err + } + + return &Login{ + credentialBase: base, + Login: conf[configKeyLoginLogin], + }, nil +} + +func (lp *Login) ID() entity.Id { + h := sha256.New() + _, _ = h.Write(lp.salt) + _, _ = h.Write([]byte(lp.target)) + _, _ = h.Write([]byte(lp.Login)) + return entity.Id(fmt.Sprintf("%x", h.Sum(nil))) +} + +func (lp *Login) Kind() CredentialKind { + return KindLogin +} + +func (lp *Login) Validate() error { + err := lp.credentialBase.validate() + if err != nil { + return err + } + if lp.Login == "" { + return fmt.Errorf("missing login") + } + return nil +} + +func (lp *Login) toConfig() map[string]string { + return map[string]string{ + configKeyLoginLogin: lp.Login, + } +} diff --git a/bridge/core/auth/login_password.go b/bridge/core/auth/login_password.go new file mode 100644 index 00000000..1981026a --- /dev/null +++ b/bridge/core/auth/login_password.go @@ -0,0 +1,76 @@ +package auth + +import ( + "crypto/sha256" + "fmt" + + "github.com/MichaelMure/git-bug/entity" +) + +const ( + configKeyLoginPasswordLogin = "login" + configKeyLoginPasswordPassword = "password" +) + +var _ Credential = &LoginPassword{} + +type LoginPassword struct { + *credentialBase + Login string + Password string +} + +func NewLoginPassword(target, login, password string) *LoginPassword { + return &LoginPassword{ + credentialBase: newCredentialBase(target), + Login: login, + Password: password, + } +} + +func NewLoginPasswordFromConfig(conf map[string]string) (*LoginPassword, error) { + base, err := newCredentialBaseFromConfig(conf) + if err != nil { + return nil, err + } + + return &LoginPassword{ + credentialBase: base, + Login: conf[configKeyLoginPasswordLogin], + Password: conf[configKeyLoginPasswordPassword], + }, nil +} + +func (lp *LoginPassword) ID() entity.Id { + h := sha256.New() + _, _ = h.Write(lp.salt) + _, _ = h.Write([]byte(lp.target)) + _, _ = h.Write([]byte(lp.Login)) + _, _ = h.Write([]byte(lp.Password)) + return entity.Id(fmt.Sprintf("%x", h.Sum(nil))) +} + +func (lp *LoginPassword) Kind() CredentialKind { + return KindLoginPassword +} + +func (lp *LoginPassword) Validate() error { + err := lp.credentialBase.validate() + if err != nil { + return err + } + if lp.Login == "" { + return fmt.Errorf("missing login") + } + if lp.Password == "" { + return fmt.Errorf("missing password") + } + return nil +} + +func (lp *LoginPassword) toConfig() map[string]string { + return map[string]string{ + configKeyLoginPasswordLogin: lp.Login, + configKeyLoginPasswordPassword: lp.Password, + } +} diff --git a/bridge/core/auth/login_password_test.go b/bridge/core/auth/login_password_test.go new file mode 100644 index 00000000..d9d82f52 --- /dev/null +++ b/bridge/core/auth/login_password_test.go @@ -0,0 +1,14 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoginPasswordSerial(t *testing.T) { + original := NewLoginPassword("github", "jean", "jacques") + loaded := testCredentialSerial(t, original) + assert.Equal(t, original.Login, loaded.(*LoginPassword).Login) + assert.Equal(t, original.Password, loaded.(*LoginPassword).Password) +} diff --git a/bridge/core/auth/login_test.go b/bridge/core/auth/login_test.go new file mode 100644 index 00000000..3fc4a391 --- /dev/null +++ b/bridge/core/auth/login_test.go @@ -0,0 +1,13 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLoginSerial(t *testing.T) { + original := NewLogin("github", "jean") + loaded := testCredentialSerial(t, original) + assert.Equal(t, original.Login, loaded.(*Login).Login) +} diff --git a/bridge/core/auth/options.go b/bridge/core/auth/options.go index 74189874..1d8c44d1 100644 --- a/bridge/core/auth/options.go +++ b/bridge/core/auth/options.go @@ -2,7 +2,7 @@ package auth type options struct { target string - kind CredentialKind + kind map[CredentialKind]interface{} meta map[string]string } @@ -21,7 +21,8 @@ func (opts *options) Match(cred Credential) bool { return false } - if opts.kind != "" && cred.Kind() != opts.kind { + _, has := opts.kind[cred.Kind()] + if len(opts.kind) > 0 && !has { return false } @@ -40,9 +41,13 @@ func WithTarget(target string) Option { } } +// WithKind match credentials with the given kind. Can be specified multiple times. func WithKind(kind CredentialKind) Option { return func(opts *options) { - opts.kind = kind + if opts.kind == nil { + opts.kind = make(map[CredentialKind]interface{}) + } + opts.kind[kind] = nil } } diff --git a/bridge/core/auth/token.go b/bridge/core/auth/token.go index 42f960bf..1f019f44 100644 --- a/bridge/core/auth/token.go +++ b/bridge/core/auth/token.go @@ -3,104 +3,68 @@ package auth import ( "crypto/sha256" "fmt" - "time" - "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/entity" - "github.com/MichaelMure/git-bug/repository" ) const ( - tokenValueKey = "value" + configKeyTokenValue = "value" ) var _ Credential = &Token{} // Token holds an API access token data type Token struct { - target string - createTime time.Time - Value string - meta map[string]string + *credentialBase + Value string } // NewToken instantiate a new token -func NewToken(value, target string) *Token { +func NewToken(target, value string) *Token { return &Token{ - target: target, - createTime: time.Now(), - Value: value, + credentialBase: newCredentialBase(target), + Value: value, } } -func NewTokenFromConfig(conf map[string]string) *Token { - token := &Token{} - - token.target = conf[configKeyTarget] - if createTime, ok := conf[configKeyCreateTime]; ok { - if t, err := repository.ParseTimestamp(createTime); err == nil { - token.createTime = t - } +func NewTokenFromConfig(conf map[string]string) (*Token, error) { + base, err := newCredentialBaseFromConfig(conf) + if err != nil { + return nil, err } - token.Value = conf[tokenValueKey] - token.meta = metaFromConfig(conf) - - return token + return &Token{ + credentialBase: base, + Value: conf[configKeyTokenValue], + }, nil } func (t *Token) ID() entity.Id { - sum := sha256.Sum256([]byte(t.target + t.Value)) - return entity.Id(fmt.Sprintf("%x", sum)) -} - -func (t *Token) Target() string { - return t.target + h := sha256.New() + _, _ = h.Write(t.salt) + _, _ = h.Write([]byte(t.target)) + _, _ = h.Write([]byte(t.Value)) + return entity.Id(fmt.Sprintf("%x", h.Sum(nil))) } func (t *Token) Kind() CredentialKind { return KindToken } -func (t *Token) CreateTime() time.Time { - return t.createTime -} - // Validate ensure token important fields are valid func (t *Token) Validate() error { + err := t.credentialBase.validate() + if err != nil { + return err + } if t.Value == "" { return fmt.Errorf("missing value") } - if t.target == "" { - return fmt.Errorf("missing target") - } - if t.createTime.IsZero() || t.createTime.Equal(time.Time{}) { - return fmt.Errorf("missing creation time") - } - if !core.TargetExist(t.target) { - return fmt.Errorf("unknown target") - } 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, + configKeyTokenValue: t.Value, } } diff --git a/bridge/core/auth/token_test.go b/bridge/core/auth/token_test.go new file mode 100644 index 00000000..d8cd6652 --- /dev/null +++ b/bridge/core/auth/token_test.go @@ -0,0 +1,13 @@ +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTokenSerial(t *testing.T) { + original := NewToken("github", "value") + loaded := testCredentialSerial(t, original) + assert.Equal(t, original.Value, loaded.(*Token).Value) +} diff --git a/bridge/core/bridge.go b/bridge/core/bridge.go index ac0d47d7..8c1f9714 100644 --- a/bridge/core/bridge.go +++ b/bridge/core/bridge.go @@ -4,6 +4,7 @@ package core import ( "context" "fmt" + "os" "reflect" "regexp" "sort" @@ -30,18 +31,6 @@ const ( var bridgeImpl map[string]reflect.Type var bridgeLoginMetaKey map[string]string -// BridgeParams holds parameters to simplify the bridge configuration without -// having to make terminal prompts. -type BridgeParams struct { - Owner string // owner of the repo (Github) - Project string // name of the repo (Github, Launchpad) - URL string // complete URL of a repo (Github, Gitlab, Launchpad) - BaseURL string // base URL for self-hosted instance ( Gitlab) - CredPrefix string // ID prefix of the credential to use (Github, Gitlab) - TokenRaw string // pre-existing token to use (Github, Gitlab) - Login string // username for the passed credential (Github, Gitlab) -} - // Bridge is a wrapper around a BridgeImpl that will bind low-level // implementation with utility code to provide high-level functions. type Bridge struct { @@ -63,7 +52,7 @@ func Register(impl BridgeImpl) { if bridgeLoginMetaKey == nil { bridgeLoginMetaKey = make(map[string]string) } - bridgeImpl[impl.Target()] = reflect.TypeOf(impl) + bridgeImpl[impl.Target()] = reflect.TypeOf(impl).Elem() bridgeLoginMetaKey[impl.Target()] = impl.LoginMetaKey() } @@ -105,7 +94,7 @@ func NewBridge(repo *cache.RepoCache, target string, name string) (*Bridge, erro return nil, fmt.Errorf("unknown bridge target %v", target) } - impl := reflect.New(implType).Elem().Interface().(BridgeImpl) + impl := reflect.New(implType).Interface().(BridgeImpl) bridge := &Bridge{ Name: name, @@ -166,10 +155,7 @@ func ConfiguredBridges(repo repository.RepoConfig) ([]string, error) { return nil, errors.Wrap(err, "can't read configured bridges") } - re, err := regexp.Compile(bridgeConfigKeyPrefix + `.([^.]+)`) - if err != nil { - panic(err) - } + re := regexp.MustCompile(bridgeConfigKeyPrefix + `.([^.]+)`) set := make(map[string]interface{}) @@ -205,10 +191,7 @@ func BridgeExist(repo repository.RepoConfig, name string) bool { // Remove a configured bridge func RemoveBridge(repo repository.RepoConfig, name string) error { - re, err := regexp.Compile(`^[a-zA-Z0-9]+`) - if err != nil { - panic(err) - } + re := regexp.MustCompile(`^[a-zA-Z0-9]+`) if !re.MatchString(name) { return fmt.Errorf("bad bridge fullname: %s", name) @@ -220,6 +203,8 @@ func RemoveBridge(repo repository.RepoConfig, name string) error { // Configure run the target specific configuration process func (b *Bridge) Configure(params BridgeParams) error { + validateParams(params, b.impl) + conf, err := b.impl.Configure(b.repo, params) if err != nil { return err @@ -234,6 +219,22 @@ func (b *Bridge) Configure(params BridgeParams) error { return b.storeConfig(conf) } +func validateParams(params BridgeParams, impl BridgeImpl) { + validParams := impl.ValidParams() + + paramsValue := reflect.ValueOf(params) + paramsType := paramsValue.Type() + + for i := 0; i < paramsValue.NumField(); i++ { + name := paramsType.Field(i).Name + val := paramsValue.Field(i).Interface().(string) + _, valid := validParams[name] + if val != "" && !valid { + _, _ = fmt.Fprintln(os.Stderr, params.fieldWarning(name, impl.Target())) + } + } +} + func (b *Bridge) storeConfig(conf Configuration) error { for key, val := range conf { storeKey := fmt.Sprintf("git-bug.bridge.%s.%s", b.Name, key) @@ -292,14 +293,14 @@ func (b *Bridge) getExporter() Exporter { return b.exporter } -func (b *Bridge) ensureImportInit() error { +func (b *Bridge) ensureImportInit(ctx context.Context) error { if b.initImportDone { return nil } importer := b.getImporter() if importer != nil { - err := importer.Init(b.repo, b.conf) + err := importer.Init(ctx, b.repo, b.conf) if err != nil { return err } @@ -309,22 +310,14 @@ func (b *Bridge) ensureImportInit() error { return nil } -func (b *Bridge) ensureExportInit() error { +func (b *Bridge) ensureExportInit(ctx context.Context) error { if b.initExportDone { return nil } - importer := b.getImporter() - if importer != nil { - err := importer.Init(b.repo, b.conf) - if err != nil { - return err - } - } - exporter := b.getExporter() if exporter != nil { - err := exporter.Init(b.repo, b.conf) + err := exporter.Init(ctx, b.repo, b.conf) if err != nil { return err } @@ -348,7 +341,7 @@ func (b *Bridge) ImportAllSince(ctx context.Context, since time.Time) (<-chan Im return nil, err } - err = b.ensureImportInit() + err = b.ensureImportInit(ctx) if err != nil { return nil, err } @@ -402,7 +395,7 @@ func (b *Bridge) ExportAll(ctx context.Context, since time.Time) (<-chan ExportR return nil, err } - err = b.ensureExportInit() + err = b.ensureExportInit(ctx) if err != nil { return nil, err } diff --git a/bridge/core/config.go b/bridge/core/config.go index afcda560..7f8d7e13 100644 --- a/bridge/core/config.go +++ b/bridge/core/config.go @@ -1,6 +1,8 @@ package core import ( + "fmt" + "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/identity" ) @@ -24,6 +26,7 @@ func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error { return err } if err == nil { + fmt.Printf("Current identity %v tagged with login %v\n", user.Id().Human(), login) // found one user.SetMetadata(metaKey, login) return user.CommitAsNeeded() @@ -42,5 +45,7 @@ func FinishConfig(repo *cache.RepoCache, metaKey string, login string) error { return err } + fmt.Printf("Identity %v created, set as current\n", i.Id().Human()) + return nil } diff --git a/bridge/core/export.go b/bridge/core/export.go index 4397a527..fa531c5f 100644 --- a/bridge/core/export.go +++ b/bridge/core/export.go @@ -27,12 +27,12 @@ const ( // Nothing changed on the bug ExportEventNothing - // Error happened during export - ExportEventError - // 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 ) // 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 f0a6f0c8..0b0b4c68 100644 --- a/bridge/core/import.go +++ b/bridge/core/import.go @@ -30,12 +30,12 @@ const ( // Identity has been created ImportEventIdentity - // Error happened during import - ImportEventError - // 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 ) // 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 ab2f3977..f20f1642 100644 --- a/bridge/core/interfaces.go +++ b/bridge/core/interfaces.go @@ -18,6 +18,9 @@ type BridgeImpl interface { // credentials. LoginMetaKey() string + // The set of the BridgeParams fields supported + ValidParams() map[string]interface{} + // Configure handle the user interaction and return a key/value configuration // for future use Configure(repo *cache.RepoCache, params BridgeParams) (Configuration, error) @@ -33,11 +36,11 @@ type BridgeImpl interface { } type Importer interface { - Init(repo *cache.RepoCache, conf Configuration) error + Init(ctx context.Context, repo *cache.RepoCache, conf Configuration) error ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan ImportResult, error) } type Exporter interface { - Init(repo *cache.RepoCache, conf Configuration) error + Init(ctx context.Context, repo *cache.RepoCache, conf Configuration) error ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan ExportResult, error) } diff --git a/bridge/core/params.go b/bridge/core/params.go new file mode 100644 index 00000000..e398b81a --- /dev/null +++ b/bridge/core/params.go @@ -0,0 +1,36 @@ +package core + +import "fmt" + +// BridgeParams holds parameters to simplify the bridge configuration without +// having to make terminal prompts. +type BridgeParams struct { + URL string // complete URL of a repo (Github, Gitlab, , Launchpad) + BaseURL string // base URL for self-hosted instance ( Gitlab, Jira, ) + Login string // username for the passed credential (Github, Gitlab, Jira, ) + CredPrefix string // ID prefix of the credential to use (Github, Gitlab, Jira, ) + TokenRaw string // pre-existing token to use (Github, Gitlab, , ) + Owner string // owner of the repo (Github, , , ) + Project string // name of the repo or project key (Github, , Jira, Launchpad) +} + +func (BridgeParams) fieldWarning(field string, target string) string { + switch field { + case "URL": + return fmt.Sprintf("warning: --url is ineffective for a %s bridge", target) + case "BaseURL": + return fmt.Sprintf("warning: --base-url is ineffective for a %s bridge", target) + case "Login": + return fmt.Sprintf("warning: --login is ineffective for a %s bridge", target) + case "CredPrefix": + return fmt.Sprintf("warning: --credential is ineffective for a %s bridge", target) + case "TokenRaw": + return fmt.Sprintf("warning: tokens are ineffective for a %s bridge", target) + case "Owner": + return fmt.Sprintf("warning: --owner is ineffective for a %s bridge", target) + case "Project": + return fmt.Sprintf("warning: --project is ineffective for a %s bridge", target) + default: + panic("unknown field") + } +} diff --git a/bridge/github/config.go b/bridge/github/config.go index cc312230..b6f69d74 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -1,7 +1,6 @@ package github import ( - "bufio" "bytes" "context" "encoding/json" @@ -10,14 +9,11 @@ import ( "io/ioutil" "math/rand" "net/http" - "os" "regexp" "sort" - "strconv" "strings" "time" - text "github.com/MichaelMure/go-term-text" "github.com/pkg/errors" "github.com/MichaelMure/git-bug/bridge/core" @@ -25,19 +21,24 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/colors" ) var ( ErrBadProjectURL = errors.New("bad project url") ) -func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.BaseURL != "" { - fmt.Println("warning: --base-url is ineffective for a Github bridge") +func (g *Github) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "Login": nil, + "CredPrefix": nil, + "TokenRaw": nil, + "Owner": nil, + "Project": nil, } +} - conf := make(core.Configuration) +func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var owner string var project string @@ -86,7 +87,7 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor } login = l case params.TokenRaw != "": - token := auth.NewToken(params.TokenRaw, target) + token := auth.NewToken(target, params.TokenRaw) login, err = getLoginFromToken(token) if err != nil { return nil, err @@ -121,9 +122,10 @@ func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyOwner] = owner - conf[keyProject] = project + conf[confKeyOwner] = owner + conf[confKeyProject] = project err = g.ValidateConfig(conf) if err != nil { @@ -148,18 +150,18 @@ func (*Github) ValidateConfig(conf core.Configuration) error { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyOwner]; !ok { - return fmt.Errorf("missing %s key", keyOwner) + if _, ok := conf[confKeyOwner]; !ok { + return fmt.Errorf("missing %s key", confKeyOwner) } - if _, ok := conf[keyProject]; !ok { - return fmt.Errorf("missing %s key", keyProject) + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) } return nil } -func usernameValidator(name string, value string) (string, error) { +func usernameValidator(_ string, value string) (string, error) { ok, err := validateUsername(value) if err != nil { return "", err @@ -241,67 +243,36 @@ func randomFingerprint() string { } func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) { - for { - creds, err := auth.List(repo, - auth.WithTarget(target), - auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyLogin, login), - ) - if err != nil { - return nil, err - } - - fmt.Println() - fmt.Println("[1]: enter my token") - fmt.Println("[2]: interactive token creation") - - if len(creds) > 0 { - sort.Sort(auth.ById(creds)) - - fmt.Println() - fmt.Println("Existing tokens for Github:") - for i, cred := range creds { - token := cred.(*auth.Token) - fmt.Printf("[%d]: %s => %s (login: %s, %s)\n", - i+3, - colors.Cyan(token.ID().Human()), - colors.Red(text.TruncateMax(token.Value, 10)), - token.Metadata()[auth.MetaKeyLogin], - token.CreateTime().Format(time.RFC822), - ) - } - } - - fmt.Println() - fmt.Print("Select option: ") + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + ) + if err != nil { + return nil, err + } - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Println() + cred, index, err := input.PromptCredential(target, "token", creds, []string{ + "enter my token", + "interactive token creation", + }) + switch { + case err != nil: + return nil, err + case cred != nil: + return cred, nil + case index == 0: + return promptToken() + case index == 1: + value, err := loginAndRequestToken(login, owner, project) if err != nil { return nil, err } - - line = strings.TrimSpace(line) - index, err := strconv.Atoi(line) - if err != nil || index < 1 || index > len(creds)+2 { - fmt.Println("invalid input") - continue - } - - switch index { - case 1: - return promptToken() - case 2: - value, err := loginAndRequestToken(login, owner, project) - if err != nil { - return nil, err - } - token := auth.NewToken(value, target) - token.SetMetadata(auth.MetaKeyLogin, login) - return token, nil - default: - return creds[index-3], nil - } + token := auth.NewToken(target, value) + token.SetMetadata(auth.MetaKeyLogin, login) + return token, nil + default: + panic("missed case") } } @@ -316,10 +287,7 @@ func promptToken() (*auth.Token, error) { fmt.Println(" - 'repo' : to be able to read private repositories") fmt.Println() - re, err := regexp.Compile(`^[a-zA-Z0-9]{40}$`) - if err != nil { - panic("regexp compile:" + err.Error()) - } + re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`) var login string @@ -327,7 +295,7 @@ func promptToken() (*auth.Token, error) { if !re.MatchString(value) { return "token has incorrect format", nil } - login, err = getLoginFromToken(auth.NewToken(value, target)) + login, err = getLoginFromToken(auth.NewToken(target, value)) if err != nil { return fmt.Sprintf("token is invalid: %v", err), nil } @@ -339,7 +307,7 @@ func promptToken() (*auth.Token, error) { return nil, err } - token := auth.NewToken(rawToken, target) + token := auth.NewToken(target, rawToken) token.SetMetadata(auth.MetaKeyLogin, login) return token, nil @@ -356,31 +324,19 @@ func loginAndRequestToken(login, owner, project string) (string, error) { fmt.Println() // prompt project visibility to know the token scope needed for the repository - i, err := input.PromptChoice("repository visibility", []string{"public", "private"}) + index, err := input.PromptChoice("repository visibility", []string{"public", "private"}) if err != nil { return "", err } - isPublic := i == 0 + scope := []string{"public_repo", "repo"}[index] password, err := input.PromptPassword("Password", "password", input.Required) if err != nil { return "", err } - var scope string - if isPublic { - // public_repo is requested to be able to read public repositories - scope = "public_repo" - } else { - // 'repo' is request to be able to read private repositories - // /!\ token will have read/write rights on every private repository you have access to - scope = "repo" - } - // Attempt to authenticate and create a token - note := fmt.Sprintf("git-bug - %s/%s", owner, project) - resp, err := requestToken(note, login, password, scope) if err != nil { return "", err @@ -413,73 +369,25 @@ func loginAndRequestToken(login, owner, project string) (string, error) { } func promptURL(repo repository.RepoCommon) (string, string, error) { - // remote suggestions - remotes, err := repo.GetRemotes() + validRemotes, err := getValidGithubRemoteURLs(repo) if err != nil { return "", "", err } - validRemotes := getValidGithubRemoteURLs(remotes) - if len(validRemotes) > 0 { - for { - fmt.Println("\nDetected projects:") - - // print valid remote github urls - for i, remote := range validRemotes { - fmt.Printf("[%d]: %v\n", i+1, remote) - } - - fmt.Printf("\n[0]: Another project\n\n") - fmt.Printf("Select option: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", "", err - } - - line = strings.TrimSpace(line) - - index, err := strconv.Atoi(line) - if err != nil || index < 0 || index > len(validRemotes) { - fmt.Println("invalid input") - continue - } - - // if user want to enter another project url break this loop - if index == 0 { - break - } - - // get owner and project with index - owner, project, _ := splitURL(validRemotes[index-1]) - return owner, project, nil - } - } - - // manually enter github url - for { - fmt.Print("Github project URL: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') + validator := func(name, value string) (string, error) { + _, _, err := splitURL(value) if err != nil { - return "", "", err - } - - line = strings.TrimSpace(line) - if line == "" { - fmt.Println("URL is empty") - continue - } - - // get owner and project from url - owner, project, err := splitURL(line) - if err != nil { - fmt.Println(err) - continue + return err.Error(), nil } + return "", nil + } - return owner, project, nil + url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator) + if err != nil { + return "", "", err } + + return splitURL(url) } // splitURL extract the owner and project from a github repository URL. It will remove the @@ -488,10 +396,7 @@ func promptURL(repo repository.RepoCommon) (string, string, error) { func splitURL(url string) (owner string, project string, err error) { cleanURL := strings.TrimSuffix(url, ".git") - re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`) - if err != nil { - panic("regexp compile:" + err.Error()) - } + re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`) res := re.FindStringSubmatch(cleanURL) if res == nil { @@ -503,7 +408,12 @@ func splitURL(url string) (owner string, project string, err error) { return } -func getValidGithubRemoteURLs(remotes map[string]string) []string { +func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) { + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + urls := make([]string, 0, len(remotes)) for _, url := range remotes { // split url can work again with shortURL @@ -516,7 +426,7 @@ func getValidGithubRemoteURLs(remotes map[string]string) []string { sort.Strings(urls) - return urls + return urls, nil } func validateUsername(username string) (bool, error) { diff --git a/bridge/github/config_test.go b/bridge/github/config_test.go index d7b1b38d..fe54c209 100644 --- a/bridge/github/config_test.go +++ b/bridge/github/config_test.go @@ -154,8 +154,8 @@ func TestValidateProject(t *testing.T) { t.Skip("Env var GITHUB_TOKEN_PUBLIC missing") } - tokenPrivate := auth.NewToken(envPrivate, target) - tokenPublic := auth.NewToken(envPublic, target) + tokenPrivate := auth.NewToken(target, envPrivate) + tokenPublic := auth.NewToken(target, envPublic) type args struct { owner string diff --git a/bridge/github/export.go b/bridge/github/export.go index c363e188..12b62fa6 100644 --- a/bridge/github/export.go +++ b/bridge/github/export.go @@ -53,7 +53,7 @@ type githubExporter struct { } // Init . -func (ge *githubExporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (ge *githubExporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { ge.conf = conf ge.identityClient = make(map[entity.Id]*githubv4.Client) ge.cachedOperationIDs = make(map[entity.Id]string) @@ -139,8 +139,8 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, ge.repositoryID, err = getRepositoryNodeID( ctx, ge.defaultToken, - ge.conf[keyOwner], - ge.conf[keyProject], + ge.conf[confKeyOwner], + ge.conf[confKeyProject], ) if err != nil { return nil, err @@ -187,7 +187,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - ge.exportBug(ctx, b, since, out) + ge.exportBug(ctx, b, out) } } } @@ -197,7 +197,7 @@ func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, } // exportBug publish bugs and related events -func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) { +func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) { snapshot := b.Snapshot() var bugUpdated bool @@ -238,7 +238,7 @@ func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc } // ignore issue coming from other repositories - if owner != ge.conf[keyOwner] && project != ge.conf[keyProject] { + if owner != ge.conf[confKeyOwner] && project != ge.conf[confKeyProject] { out <- core.NewExportNothing(b.Id(), fmt.Sprintf("skipping issue from url:%s", githubURL)) return } @@ -481,8 +481,8 @@ func markOperationAsExported(b *cache.BugCache, target entity.Id, githubID, gith func (ge *githubExporter) cacheGithubLabels(ctx context.Context, gc *githubv4.Client) error { variables := map[string]interface{}{ - "owner": githubv4.String(ge.conf[keyOwner]), - "name": githubv4.String(ge.conf[keyProject]), + "owner": githubv4.String(ge.conf[confKeyOwner]), + "name": githubv4.String(ge.conf[confKeyProject]), "first": githubv4.Int(10), "after": (*githubv4.String)(nil), } @@ -526,7 +526,7 @@ func (ge *githubExporter) getLabelID(gc *githubv4.Client, label string) (string, // NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels // see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview func (ge *githubExporter) createGithubLabel(ctx context.Context, label, color string) (string, error) { - url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject]) + url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[confKeyOwner], ge.conf[confKeyProject]) client := &http.Client{} params := struct { diff --git a/bridge/github/export_test.go b/bridge/github/export_test.go index 7d6e6fb1..acbd657a 100644 --- a/bridge/github/export_test.go +++ b/bridge/github/export_test.go @@ -157,7 +157,7 @@ func TestPushPull(t *testing.T) { defer backend.Close() interrupt.RegisterCleaner(backend.Close) - token := auth.NewToken(envToken, target) + token := auth.NewToken(target, envToken) token.SetMetadata(auth.MetaKeyLogin, login) err = auth.Store(repo, token) require.NoError(t, err) @@ -185,15 +185,16 @@ func TestPushPull(t *testing.T) { return deleteRepository(projectName, envUser, envToken) }) + ctx := context.Background() + // initialize exporter exporter := &githubExporter{} - err = exporter.Init(backend, core.Configuration{ - keyOwner: envUser, - keyProject: projectName, + err = exporter.Init(ctx, backend, core.Configuration{ + confKeyOwner: envUser, + confKeyProject: projectName, }) require.NoError(t, err) - ctx := context.Background() start := time.Now() // export all bugs @@ -215,9 +216,9 @@ func TestPushPull(t *testing.T) { require.NoError(t, err) importer := &githubImporter{} - err = importer.Init(backend, core.Configuration{ - keyOwner: envUser, - keyProject: projectName, + err = importer.Init(ctx, backend, core.Configuration{ + confKeyOwner: envUser, + confKeyProject: projectName, }) require.NoError(t, err) diff --git a/bridge/github/github.go b/bridge/github/github.go index 19dc8a08..3a99cec7 100644 --- a/bridge/github/github.go +++ b/bridge/github/github.go @@ -19,8 +19,8 @@ const ( metaKeyGithubUrl = "github-url" metaKeyGithubLogin = "github-login" - keyOwner = "owner" - keyProject = "project" + confKeyOwner = "owner" + confKeyProject = "project" githubV3Url = "https://api.github.com" defaultTimeout = 60 * time.Second diff --git a/bridge/github/import.go b/bridge/github/import.go index f7840217..e80b9cfd 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -29,7 +29,7 @@ type githubImporter struct { out chan<- core.ImportResult } -func (gi *githubImporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (gi *githubImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { gi.conf = conf creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken)) @@ -49,7 +49,7 @@ func (gi *githubImporter) Init(repo *cache.RepoCache, conf core.Configuration) e // ImportAll iterate over all the configured repository issues and ensure the creation of the // missing issues / timeline items / edits / label events ... func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { - gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyOwner], gi.conf[keyProject], since) + gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[confKeyOwner], gi.conf[confKeyProject], since) out := make(chan core.ImportResult) gi.out = out diff --git a/bridge/github/import_test.go b/bridge/github/import_test.go index a8f8e346..20b1b71e 100644 --- a/bridge/github/import_test.go +++ b/bridge/github/import_test.go @@ -144,19 +144,20 @@ func Test_Importer(t *testing.T) { login := "test-identity" author.SetMetadata(metaKeyGithubLogin, login) - token := auth.NewToken(envToken, target) + token := auth.NewToken(target, envToken) token.SetMetadata(auth.MetaKeyLogin, login) err = auth.Store(repo, token) require.NoError(t, err) + ctx := context.Background() + importer := &githubImporter{} - err = importer.Init(backend, core.Configuration{ - keyOwner: "MichaelMure", - keyProject: "git-bug-test-github-bridge", + err = importer.Init(ctx, backend, core.Configuration{ + confKeyOwner: "MichaelMure", + confKeyProject: "git-bug-test-github-bridge", }) require.NoError(t, err) - ctx := context.Background() start := time.Now() events, err := importer.ImportAll(ctx, backend, time.Time{}) diff --git a/bridge/gitlab/config.go b/bridge/gitlab/config.go index 62d385dc..d5dc418c 100644 --- a/bridge/gitlab/config.go +++ b/bridge/gitlab/config.go @@ -1,18 +1,13 @@ package gitlab import ( - "bufio" "fmt" "net/url" - "os" "path" "regexp" - "sort" "strconv" "strings" - "time" - text "github.com/MichaelMure/go-term-text" "github.com/pkg/errors" "github.com/xanzy/go-gitlab" @@ -21,22 +16,23 @@ import ( "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/input" "github.com/MichaelMure/git-bug/repository" - "github.com/MichaelMure/git-bug/util/colors" ) var ( ErrBadProjectURL = errors.New("bad project url") ) -func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.Project != "" { - fmt.Println("warning: --project is ineffective for a gitlab bridge") - } - if params.Owner != "" { - fmt.Println("warning: --owner is ineffective for a gitlab bridge") +func (g *Gitlab) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "BaseURL": nil, + "Login": nil, + "CredPrefix": nil, + "TokenRaw": nil, } +} - conf := make(core.Configuration) +func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var baseUrl string @@ -44,7 +40,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor case params.BaseURL != "": baseUrl = params.BaseURL default: - baseUrl, err = promptBaseUrlOptions() + baseUrl, err = input.PromptDefault("Gitlab server URL", "URL", defaultBaseURL, input.Required, input.IsURL) if err != nil { return nil, errors.Wrap(err, "base url prompt") } @@ -83,7 +79,7 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor } login = l case params.TokenRaw != "": - token := auth.NewToken(params.TokenRaw, target) + token := auth.NewToken(target, params.TokenRaw) login, err = getLoginFromToken(baseUrl, token) if err != nil { return nil, err @@ -117,9 +113,10 @@ func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (cor return nil, errors.Wrap(err, "project validation") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyProjectID] = strconv.Itoa(id) - conf[keyGitlabBaseUrl] = baseUrl + conf[confKeyProjectID] = strconv.Itoa(id) + conf[confKeyGitlabBaseUrl] = baseUrl err = g.ValidateConfig(conf) if err != nil { @@ -143,107 +140,39 @@ func (g *Gitlab) ValidateConfig(conf core.Configuration) error { } else if v != target { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyGitlabBaseUrl]; !ok { - return fmt.Errorf("missing %s key", keyGitlabBaseUrl) + if _, ok := conf[confKeyGitlabBaseUrl]; !ok { + return fmt.Errorf("missing %s key", confKeyGitlabBaseUrl) } - if _, ok := conf[keyProjectID]; !ok { - return fmt.Errorf("missing %s key", keyProjectID) + if _, ok := conf[confKeyProjectID]; !ok { + return fmt.Errorf("missing %s key", confKeyProjectID) } return nil } -func promptBaseUrlOptions() (string, error) { - index, err := input.PromptChoice("Gitlab base url", []string{ - "https://gitlab.com", - "enter your own base url", - }) - +func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), + ) if err != nil { - return "", err - } - - if index == 0 { - return defaultBaseURL, nil - } else { - return promptBaseUrl() - } -} - -func promptBaseUrl() (string, error) { - validator := func(name string, value string) (string, error) { - u, err := url.Parse(value) - if err != nil { - return err.Error(), nil - } - if u.Scheme == "" { - return "missing scheme", nil - } - if u.Host == "" { - return "missing host", nil - } - return "", nil + return nil, err } - return input.Prompt("Base url", "url", input.Required, validator) -} - -func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { - for { - creds, err := auth.List(repo, - auth.WithTarget(target), - auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyLogin, login), - auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), - ) - if err != nil { - return nil, err - } - - // if we don't have existing token, fast-track to the token prompt - if len(creds) == 0 { - return promptToken(baseUrl) - } - - fmt.Println() - fmt.Println("[1]: enter my token") - - fmt.Println() - fmt.Println("Existing tokens for Gitlab:") - - sort.Sort(auth.ById(creds)) - for i, cred := range creds { - token := cred.(*auth.Token) - fmt.Printf("[%d]: %s => %s (%s)\n", - i+2, - colors.Cyan(token.ID().Human()), - colors.Red(text.TruncateMax(token.Value, 10)), - token.CreateTime().Format(time.RFC822), - ) - } - - fmt.Println() - fmt.Print("Select option: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - fmt.Println() - if err != nil { - return nil, err - } - - line = strings.TrimSpace(line) - index, err := strconv.Atoi(line) - if err != nil || index < 1 || index > len(creds)+1 { - fmt.Println("invalid input") - continue - } - - switch index { - case 1: - return promptToken(baseUrl) - default: - return creds[index-2], nil - } + cred, index, err := input.PromptCredential(target, "token", creds, []string{ + "enter my token", + }) + switch { + case err != nil: + return nil, err + case cred != nil: + return cred, nil + case index == 0: + return promptToken(baseUrl) + default: + panic("missed case") } } @@ -254,10 +183,7 @@ func promptToken(baseUrl string) (*auth.Token, error) { fmt.Println("'api' access scope: to be able to make api calls") fmt.Println() - re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}$`) - if err != nil { - panic("regexp compile:" + err.Error()) - } + re := regexp.MustCompile(`^[a-zA-Z0-9\-\_]{20}$`) var login string @@ -265,7 +191,7 @@ func promptToken(baseUrl string) (*auth.Token, error) { if !re.MatchString(value) { return "token has incorrect format", nil } - login, err = getLoginFromToken(baseUrl, auth.NewToken(value, target)) + login, err = getLoginFromToken(baseUrl, auth.NewToken(target, value)) if err != nil { return fmt.Sprintf("token is invalid: %v", err), nil } @@ -277,7 +203,7 @@ func promptToken(baseUrl string) (*auth.Token, error) { return nil, err } - token := auth.NewToken(rawToken, target) + token := auth.NewToken(target, rawToken) token.SetMetadata(auth.MetaKeyLogin, login) token.SetMetadata(auth.MetaKeyBaseURL, baseUrl) @@ -285,64 +211,12 @@ func promptToken(baseUrl string) (*auth.Token, error) { } func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) { - // remote suggestions - remotes, err := repo.GetRemotes() + validRemotes, err := getValidGitlabRemoteURLs(repo, baseUrl) if err != nil { - return "", errors.Wrap(err, "getting remotes") - } - - validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes) - if len(validRemotes) > 0 { - for { - fmt.Println("\nDetected projects:") - - // print valid remote gitlab urls - for i, remote := range validRemotes { - fmt.Printf("[%d]: %v\n", i+1, remote) - } - - fmt.Printf("\n[0]: Another project\n\n") - fmt.Printf("Select option: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", err - } - - line = strings.TrimSpace(line) - - index, err := strconv.Atoi(line) - if err != nil || index < 0 || index > len(validRemotes) { - fmt.Println("invalid input") - continue - } - - // if user want to enter another project url break this loop - if index == 0 { - break - } - - return validRemotes[index-1], nil - } + return "", err } - // manually enter gitlab url - for { - fmt.Print("Gitlab project URL: ") - - line, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", err - } - - projectURL := strings.TrimSpace(line) - if projectURL == "" { - fmt.Println("URL is empty") - continue - } - - return projectURL, nil - } + return input.PromptURLWithRemote("Gitlab project URL", "URL", validRemotes, input.Required) } func getProjectPath(baseUrl, projectUrl string) (string, error) { @@ -364,7 +238,12 @@ func getProjectPath(baseUrl, projectUrl string) (string, error) { return objectUrl.Path[1:], nil } -func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string { +func getValidGitlabRemoteURLs(repo repository.RepoCommon, baseUrl string) ([]string, error) { + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + urls := make([]string, 0, len(remotes)) for _, u := range remotes { path, err := getProjectPath(baseUrl, u) @@ -375,7 +254,7 @@ func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []strin urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path)) } - return urls + return urls, nil } func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) { diff --git a/bridge/gitlab/export.go b/bridge/gitlab/export.go index d747c6ac..918e6b5e 100644 --- a/bridge/gitlab/export.go +++ b/bridge/gitlab/export.go @@ -38,16 +38,16 @@ type gitlabExporter struct { } // Init . -func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (ge *gitlabExporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { ge.conf = conf ge.identityClient = make(map[entity.Id]*gitlab.Client) ge.cachedOperationIDs = make(map[string]string) // get repository node id - ge.repositoryID = ge.conf[keyProjectID] + ge.repositoryID = ge.conf[confKeyProjectID] // preload all clients - err := ge.cacheAllClient(repo, ge.conf[keyGitlabBaseUrl]) + err := ge.cacheAllClient(repo, ge.conf[confKeyGitlabBaseUrl]) if err != nil { return err } @@ -81,7 +81,7 @@ func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache, baseURL string) } if _, ok := ge.identityClient[user.Id()]; !ok { - client, err := buildClient(ge.conf[keyGitlabBaseUrl], creds[0].(*auth.Token)) + client, err := buildClient(ge.conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token)) if err != nil { return err } @@ -138,7 +138,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, if snapshot.HasAnyActor(allIdentitiesIds...) { // try to export the bug and it associated events - ge.exportBug(ctx, b, since, out) + ge.exportBug(ctx, b, out) } } } @@ -148,7 +148,7 @@ func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, } // exportBug publish bugs and related events -func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) { +func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) { snapshot := b.Snapshot() var bugUpdated bool @@ -177,7 +177,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc gitlabID, ok := snapshot.GetCreateMetadata(metaKeyGitlabId) if ok { gitlabBaseUrl, ok := snapshot.GetCreateMetadata(metaKeyGitlabBaseUrl) - if ok && gitlabBaseUrl != ge.conf[keyGitlabBaseUrl] { + if ok && gitlabBaseUrl != ge.conf[confKeyGitlabBaseUrl] { out <- core.NewExportNothing(b.Id(), "skipping issue imported from another Gitlab instance") return } @@ -189,7 +189,7 @@ func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, sinc return } - if projectID != ge.conf[keyProjectID] { + if projectID != ge.conf[confKeyProjectID] { out <- core.NewExportNothing(b.Id(), "skipping issue imported from another repository") return } diff --git a/bridge/gitlab/export_test.go b/bridge/gitlab/export_test.go index c97416d8..d704ac3b 100644 --- a/bridge/gitlab/export_test.go +++ b/bridge/gitlab/export_test.go @@ -162,7 +162,7 @@ func TestPushPull(t *testing.T) { defer backend.Close() interrupt.RegisterCleaner(backend.Close) - token := auth.NewToken(envToken, target) + token := auth.NewToken(target, envToken) token.SetMetadata(auth.MetaKeyLogin, login) token.SetMetadata(auth.MetaKeyBaseURL, defaultBaseURL) err = auth.Store(repo, token) @@ -191,15 +191,16 @@ func TestPushPull(t *testing.T) { return deleteRepository(context.TODO(), projectID, token) }) + ctx := context.Background() + // initialize exporter exporter := &gitlabExporter{} - err = exporter.Init(backend, core.Configuration{ - keyProjectID: strconv.Itoa(projectID), - keyGitlabBaseUrl: defaultBaseURL, + err = exporter.Init(ctx, backend, core.Configuration{ + confKeyProjectID: strconv.Itoa(projectID), + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) - ctx := context.Background() start := time.Now() // export all bugs @@ -221,9 +222,9 @@ func TestPushPull(t *testing.T) { require.NoError(t, err) importer := &gitlabImporter{} - err = importer.Init(backend, core.Configuration{ - keyProjectID: strconv.Itoa(projectID), - keyGitlabBaseUrl: defaultBaseURL, + err = importer.Init(ctx, backend, core.Configuration{ + confKeyProjectID: strconv.Itoa(projectID), + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) diff --git a/bridge/gitlab/gitlab.go b/bridge/gitlab/gitlab.go index 8512379c..ec7b7e57 100644 --- a/bridge/gitlab/gitlab.go +++ b/bridge/gitlab/gitlab.go @@ -19,8 +19,8 @@ const ( metaKeyGitlabProject = "gitlab-project-id" metaKeyGitlabBaseUrl = "gitlab-base-url" - keyProjectID = "project-id" - keyGitlabBaseUrl = "base-url" + confKeyProjectID = "project-id" + confKeyGitlabBaseUrl = "base-url" defaultBaseURL = "https://gitlab.com/" defaultTimeout = 60 * time.Second @@ -30,7 +30,7 @@ var _ core.BridgeImpl = &Gitlab{} type Gitlab struct{} -func (*Gitlab) Target() string { +func (Gitlab) Target() string { return target } @@ -38,11 +38,11 @@ func (g *Gitlab) LoginMetaKey() string { return metaKeyGitlabLogin } -func (*Gitlab) NewImporter() core.Importer { +func (Gitlab) NewImporter() core.Importer { return &gitlabImporter{} } -func (*Gitlab) NewExporter() core.Exporter { +func (Gitlab) NewExporter() core.Exporter { return &gitlabExporter{} } diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go index 4fccb47e..5faf5c48 100644 --- a/bridge/gitlab/import.go +++ b/bridge/gitlab/import.go @@ -30,13 +30,13 @@ type gitlabImporter struct { out chan<- core.ImportResult } -func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (gi *gitlabImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { gi.conf = conf creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken), - auth.WithMeta(auth.MetaKeyBaseURL, conf[keyGitlabBaseUrl]), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyGitlabBaseUrl]), ) if err != nil { return err @@ -46,7 +46,7 @@ func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) e return ErrMissingIdentityToken } - gi.client, err = buildClient(conf[keyGitlabBaseUrl], creds[0].(*auth.Token)) + gi.client, err = buildClient(conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token)) if err != nil { return err } @@ -57,7 +57,7 @@ func (gi *gitlabImporter) Init(repo *cache.RepoCache, conf core.Configuration) e // ImportAll iterate over all the configured repository issues (notes) and ensure the creation // of the missing issues / comments / label events / title changes ... func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { - gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[keyProjectID], since) + gi.iterator = NewIterator(ctx, gi.client, 10, gi.conf[confKeyProjectID], since) out := make(chan core.ImportResult) gi.out = out @@ -147,8 +147,8 @@ func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue core.MetaKeyOrigin: target, metaKeyGitlabId: parseID(issue.IID), metaKeyGitlabUrl: issue.WebURL, - metaKeyGitlabProject: gi.conf[keyProjectID], - metaKeyGitlabBaseUrl: gi.conf[keyGitlabBaseUrl], + metaKeyGitlabProject: gi.conf[confKeyProjectID], + metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl], }, ) diff --git a/bridge/gitlab/import_test.go b/bridge/gitlab/import_test.go index a300acf1..ea7acc18 100644 --- a/bridge/gitlab/import_test.go +++ b/bridge/gitlab/import_test.go @@ -98,20 +98,21 @@ func TestImport(t *testing.T) { login := "test-identity" author.SetMetadata(metaKeyGitlabLogin, login) - token := auth.NewToken(envToken, target) + token := auth.NewToken(target, envToken) token.SetMetadata(auth.MetaKeyLogin, login) token.SetMetadata(auth.MetaKeyBaseURL, defaultBaseURL) err = auth.Store(repo, token) require.NoError(t, err) + ctx := context.Background() + importer := &gitlabImporter{} - err = importer.Init(backend, core.Configuration{ - keyProjectID: projectID, - keyGitlabBaseUrl: defaultBaseURL, + err = importer.Init(ctx, backend, core.Configuration{ + confKeyProjectID: projectID, + confKeyGitlabBaseUrl: defaultBaseURL, }) require.NoError(t, err) - ctx := context.Background() start := time.Now() events, err := importer.ImportAll(ctx, backend, time.Time{}) diff --git a/bridge/jira/client.go b/bridge/jira/client.go new file mode 100644 index 00000000..5e1db26f --- /dev/null +++ b/bridge/jira/client.go @@ -0,0 +1,1461 @@ +package jira + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bug" +) + +var errDone = errors.New("Iteration Done") +var errTransitionNotFound = errors.New("Transition not found") +var errTransitionNotAllowed = errors.New("Transition not allowed") + +// ============================================================================= +// Extended JSON +// ============================================================================= + +const TimeFormat = "2006-01-02T15:04:05.999999999Z0700" + +// ParseTime parse an RFC3339 string with nanoseconds +func ParseTime(timeStr string) (time.Time, error) { + out, err := time.Parse(time.RFC3339Nano, timeStr) + if err != nil { + out, err = time.Parse(TimeFormat, timeStr) + } + return out, err +} + +// Time is just a time.Time with a JSON serialization +type Time struct { + time.Time +} + +// UnmarshalJSON parses an RFC3339 date string into a time object +// borrowed from: https://stackoverflow.com/a/39180230/141023 +func (t *Time) UnmarshalJSON(data []byte) (err error) { + str := string(data) + + // Get rid of the quotes "" around the value. + // A second option would be to include them in the date format string + // instead, like so below: + // time.Parse(`"`+time.RFC3339Nano+`"`, s) + str = str[1 : len(str)-1] + + timeObj, err := ParseTime(str) + t.Time = timeObj + return +} + +// ============================================================================= +// JSON Objects +// ============================================================================= + +// Session credential cookie name/value pair received after logging in and +// required to be sent on all subsequent requests +type Session struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// SessionResponse the JSON object returned from a /session query (login) +type SessionResponse struct { + Session Session `json:"session"` +} + +// SessionQuery the JSON object that is POSTed to the /session endpoint +// in order to login and get a session cookie +type SessionQuery struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// User the JSON object representing a JIRA user +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/user +type User struct { + DisplayName string `json:"displayName"` + EmailAddress string `json:"emailAddress"` + Key string `json:"key"` + Name string `json:"name"` +} + +// Comment the JSON object for a single comment item returned in a list of +// comments +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + Author User `json:"author"` + UpdateAuthor User `json:"updateAuthor"` + Created Time `json:"created"` + Updated Time `json:"updated"` +} + +// CommentPage the JSON object holding a single page of comments returned +// either by direct query or within an issue query +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComments +type CommentPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Comments []Comment `json:"comments"` +} + +// NextStartAt return the index of the first item on the next page +func (cp *CommentPage) NextStartAt() int { + return cp.StartAt + len(cp.Comments) +} + +// IsLastPage return true if there are no more items beyond this page +func (cp *CommentPage) IsLastPage() bool { + return cp.NextStartAt() >= cp.Total +} + +// IssueFields the JSON object returned as the "fields" member of an issue. +// There are a very large number of fields and many of them are custom. We +// only grab a few that we need. +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type IssueFields struct { + Creator User `json:"creator"` + Created Time `json:"created"` + Description string `json:"description"` + Summary string `json:"summary"` + Comments CommentPage `json:"comment"` + Labels []string `json:"labels"` +} + +// ChangeLogItem "field-change" data within a changelog entry. A single +// changelog entry might effect multiple fields. For example, closing an issue +// generally requires a change in "status" and "resolution" +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogItem struct { + Field string `json:"field"` + FieldType string `json:"fieldtype"` + From string `json:"from"` + FromString string `json:"fromString"` + To string `json:"to"` + ToString string `json:"toString"` +} + +// ChangeLogEntry One entry in a changelog +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogEntry struct { + ID string `json:"id"` + Author User `json:"author"` + Created Time `json:"created"` + Items []ChangeLogItem `json:"items"` +} + +// ChangeLogPage A collection of changes to issue metadata +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type ChangeLogPage struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + IsLast bool `json:"isLast"` // Cloud-only + Entries []ChangeLogEntry `json:"histories"` + Values []ChangeLogEntry `json:"values"` +} + +// NextStartAt return the index of the first item on the next page +func (clp *ChangeLogPage) NextStartAt() int { + return clp.StartAt + len(clp.Entries) +} + +// IsLastPage return true if there are no more items beyond this page +func (clp *ChangeLogPage) IsLastPage() bool { + // NOTE(josh): The "isLast" field is returned on JIRA cloud, but not on + // JIRA server. If we can distinguish which one we are working with, we can + // possibly rely on that instead. + return clp.NextStartAt() >= clp.Total +} + +// Issue Top-level object for an issue +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getIssue +type Issue struct { + ID string `json:"id"` + Key string `json:"key"` + Fields IssueFields `json:"fields"` + ChangeLog ChangeLogPage `json:"changelog"` +} + +// SearchResult The result type from querying the search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +type SearchResult struct { + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Total int `json:"total"` + Issues []Issue `json:"issues"` +} + +// NextStartAt return the index of the first item on the next page +func (sr *SearchResult) NextStartAt() int { + return sr.StartAt + len(sr.Issues) +} + +// IsLastPage return true if there are no more items beyond this page +func (sr *SearchResult) IsLastPage() bool { + return sr.NextStartAt() >= sr.Total +} + +// SearchRequest the JSON object POSTed to the /search endpoint +type SearchRequest struct { + JQL string `json:"jql"` + StartAt int `json:"startAt"` + MaxResults int `json:"maxResults"` + Fields []string `json:"fields"` +} + +// Project the JSON object representing a project. Note that we don't use all +// the fields so we have only implemented a couple. +type Project struct { + ID string `json:"id,omitempty"` + Key string `json:"key,omitempty"` +} + +// IssueType the JSON object representing an issue type (i.e. "bug", "task") +// Note that we don't use all the fields so we have only implemented a couple. +type IssueType struct { + ID string `json:"id"` +} + +// IssueCreateFields fields that are included in an IssueCreate request +type IssueCreateFields struct { + Project Project `json:"project"` + Summary string `json:"summary"` + Description string `json:"description"` + IssueType IssueType `json:"issuetype"` +} + +// IssueCreate the JSON object that is POSTed to the /issue endpoint to create +// a new issue +type IssueCreate struct { + Fields IssueCreateFields `json:"fields"` +} + +// IssueCreateResult the JSON object returned after issue creation. +type IssueCreateResult struct { + ID string `json:"id"` + Key string `json:"key"` +} + +// CommentCreate the JSOn object that is POSTed to the /comment endpoint to +// create a new comment +type CommentCreate struct { + Body string `json:"body"` +} + +// StatusCategory the JSON object representing a status category +type StatusCategory struct { + ID int `json:"id"` + Key string `json:"key"` + Self string `json:"self"` + ColorName string `json:"colorName"` + Name string `json:"name"` +} + +// Status the JSON object representing a status (i.e. "Open", "Closed") +type Status struct { + ID string `json:"id"` + Name string `json:"name"` + Self string `json:"self"` + Description string `json:"description"` + StatusCategory StatusCategory `json:"statusCategory"` +} + +// Transition the JSON object represenging a transition from one Status to +// another Status in a JIRA workflow +type Transition struct { + ID string `json:"id"` + Name string `json:"name"` + To Status `json:"to"` +} + +// TransitionList the JSON object returned from the /transitions endpoint +type TransitionList struct { + Transitions []Transition `json:"transitions"` +} + +// ServerInfo general server information returned by the /serverInfo endpoint. +// Notably `ServerTime` will tell you the time on the server. +type ServerInfo struct { + BaseURL string `json:"baseUrl"` + Version string `json:"version"` + VersionNumbers []int `json:"versionNumbers"` + BuildNumber int `json:"buildNumber"` + BuildDate Time `json:"buildDate"` + ServerTime Time `json:"serverTime"` + ScmInfo string `json:"scmInfo"` + BuildPartnerName string `json:"buildPartnerName"` + ServerTitle string `json:"serverTitle"` +} + +// ============================================================================= +// REST Client +// ============================================================================= + +// ClientTransport wraps http.RoundTripper by adding a +// "Content-Type=application/json" header +type ClientTransport struct { + underlyingTransport http.RoundTripper + basicAuthString string +} + +// RoundTrip overrides the default by adding the content-type header +func (ct *ClientTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add("Content-Type", "application/json") + if ct.basicAuthString != "" { + req.Header.Add("Authorization", + fmt.Sprintf("Basic %s", ct.basicAuthString)) + } + + return ct.underlyingTransport.RoundTrip(req) +} + +func (ct *ClientTransport) SetCredentials(username string, token string) { + credString := fmt.Sprintf("%s:%s", username, token) + ct.basicAuthString = base64.StdEncoding.EncodeToString([]byte(credString)) +} + +// Client Thin wrapper around the http.Client providing jira-specific methods +// for API endpoints +type Client struct { + *http.Client + serverURL string + ctx context.Context +} + +// NewClient Construct a new client connected to the provided server and +// utilizing the given context for asynchronous events +func NewClient(ctx context.Context, serverURL string) *Client { + cookiJar, _ := cookiejar.New(nil) + client := &http.Client{ + Transport: &ClientTransport{underlyingTransport: http.DefaultTransport}, + Jar: cookiJar, + } + + return &Client{client, serverURL, ctx} +} + +// Login POST credentials to the /session endpoint and get a session cookie +func (client *Client) Login(credType, login, password string) error { + switch credType { + case "SESSION": + return client.RefreshSessionToken(login, password) + case "TOKEN": + return client.SetTokenCredentials(login, password) + default: + panic("unknown Jira cred type") + } +} + +// RefreshSessionToken formulate the JSON request object from the user +// credentials and POST it to the /session endpoint and get a session cookie +func (client *Client) RefreshSessionToken(username, password string) error { + params := SessionQuery{ + Username: username, + Password: password, + } + + data, err := json.Marshal(params) + if err != nil { + return err + } + + return client.RefreshSessionTokenRaw(data) +} + +// SetTokenCredentials POST credentials to the /session endpoint and get a +// session cookie +func (client *Client) SetTokenCredentials(username, password string) error { + switch transport := client.Transport.(type) { + case *ClientTransport: + transport.SetCredentials(username, password) + default: + return fmt.Errorf("Invalid transport type") + } + return nil +} + +// RefreshSessionTokenRaw POST credentials to the /session endpoint and get a +// session cookie +func (client *Client) RefreshSessionTokenRaw(credentialsJSON []byte) error { + postURL := fmt.Sprintf("%s/rest/auth/1/session", client.serverURL) + + req, err := http.NewRequest("POST", postURL, bytes.NewBuffer(credentialsJSON)) + if err != nil { + return err + } + + urlobj, err := url.Parse(client.serverURL) + if err != nil { + fmt.Printf("Failed to parse %s\n", client.serverURL) + } else { + // Clear out cookies + client.Jar.SetCookies(urlobj, []*http.Cookie{}) + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + req = req.WithContext(ctx) + } + + response, err := client.Do(req) + if err != nil { + return err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + content, _ := ioutil.ReadAll(response.Body) + return fmt.Errorf( + "error creating token %v: %s", response.StatusCode, content) + } + + data, _ := ioutil.ReadAll(response.Body) + var aux SessionResponse + err = json.Unmarshal(data, &aux) + if err != nil { + return err + } + + var cookies []*http.Cookie + cookie := &http.Cookie{ + Name: aux.Session.Name, + Value: aux.Session.Value, + } + cookies = append(cookies, cookie) + client.Jar.SetCookies(urlobj, cookies) + + return nil +} + +// ============================================================================= +// Endpoint Wrappers +// ============================================================================= + +// Search Perform an issue a JQL search on the /search endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/search +func (client *Client) Search(jql string, maxResults int, startAt int) (*SearchResult, error) { + url := fmt.Sprintf("%s/rest/api/2/search", client.serverURL) + + requestBody, err := json.Marshal(SearchRequest{ + JQL: jql, + StartAt: startAt, + MaxResults: maxResults, + Fields: []string{ + "comment", + "created", + "creator", + "description", + "labels", + "status", + "summary"}}) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s, %s", response.StatusCode, + url, requestBody) + return nil, err + } + + var message SearchResult + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// SearchIterator cursor within paginated results from the /search endpoint +type SearchIterator struct { + client *Client + jql string + searchResult *SearchResult + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (si *SearchIterator) HasError() bool { + if si.Err == errDone { + return false + } + if si.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (si *SearchIterator) HasNext() bool { + return si.Err == nil && si.itemIdx < len(si.searchResult.Issues) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (si *SearchIterator) Next() *Issue { + if si.Err != nil { + return nil + } + + issue := si.searchResult.Issues[si.itemIdx] + if si.itemIdx+1 < len(si.searchResult.Issues) { + // We still have an item left in the currently cached page + si.itemIdx++ + } else { + if si.searchResult.IsLastPage() { + si.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + si.searchResult, si.Err = si.client.Search( + si.jql, si.pageSize, si.searchResult.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + si.itemIdx = 0 + } + } + return &issue +} + +// IterSearch return an iterator over paginated results for a JQL search +func (client *Client) IterSearch(jql string, pageSize int) *SearchIterator { + result, err := client.Search(jql, pageSize, 0) + + iter := &SearchIterator{ + client: client, + jql: jql, + searchResult: result, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetIssue fetches an issue object via the /issue/{IssueIdOrKey} endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetIssue(idOrKey string, fields []string, expand []string, + properties []string) (*Issue, error) { + + url := fmt.Sprintf("%s/rest/api/2/issue/%s", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if len(fields) > 0 { + query.Add("fields", strings.Join(fields, ",")) + } + if len(expand) > 0 { + query.Add("expand", strings.Join(expand, ",")) + } + if len(properties) > 0 { + query.Add("properties", strings.Join(properties, ",")) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var issue Issue + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &issue) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &issue, nil +} + +// GetComments returns a page of comments via the issue/{IssueIdOrKey}/comment +// endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue-getComment +func (client *Client) GetComments(idOrKey string, maxResults int, startAt int) (*CommentPage, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var comments CommentPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &comments) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &comments, nil +} + +// CommentIterator cursor within paginated results from the /comment endpoint +type CommentIterator struct { + client *Client + idOrKey string + message *CommentPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (ci *CommentIterator) HasError() bool { + if ci.Err == errDone { + return false + } + if ci.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (ci *CommentIterator) HasNext() bool { + return ci.Err == nil && ci.itemIdx < len(ci.message.Comments) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (ci *CommentIterator) Next() *Comment { + if ci.Err != nil { + return nil + } + + comment := ci.message.Comments[ci.itemIdx] + if ci.itemIdx+1 < len(ci.message.Comments) { + // We still have an item left in the currently cached page + ci.itemIdx++ + } else { + if ci.message.IsLastPage() { + ci.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + ci.message, ci.Err = ci.client.GetComments( + ci.idOrKey, ci.pageSize, ci.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + ci.itemIdx = 0 + } + } + return &comment +} + +// IterComments returns an iterator over paginated comments within an issue +func (client *Client) IterComments(idOrKey string, pageSize int) *CommentIterator { + message, err := client.GetComments(idOrKey, pageSize, 0) + + iter := &CommentIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetChangeLog fetch one page of the changelog for an issue via the +// /issue/{IssueIdOrKey}/changelog endpoint (for JIRA cloud) or +// /issue/{IssueIdOrKey} with (fields=*none&expand=changelog) +// (for JIRA server) +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetChangeLog(idOrKey string, maxResults int, startAt int) (*ChangeLogPage, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/changelog", client.serverURL, idOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + query := request.URL.Query() + if maxResults > 0 { + query.Add("maxResults", fmt.Sprintf("%d", maxResults)) + } + if startAt > 0 { + query.Add("startAt", fmt.Sprintf("%d", startAt)) + } + request.URL.RawQuery = query.Encode() + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode == http.StatusNotFound { + // The issue/{IssueIdOrKey}/changelog endpoint is only available on JIRA cloud + // products, not on JIRA server. In order to get the information we have to + // query the issue and ask for a changelog expansion. Unfortunately this means + // that the changelog is not paginated and we have to fetch the entire thing + // at once. Hopefully things don't break for very long changelogs. + issue, err := client.GetIssue( + idOrKey, []string{"*none"}, []string{"changelog"}, []string{}) + if err != nil { + return nil, err + } + + return &issue.ChangeLog, nil + } + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var changelog ChangeLogPage + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &changelog) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + // JIRA cloud returns changelog entries in the "values" list, whereas JIRA + // server returns them in the "histories" list when embedded in an issue + // object. + changelog.Entries = changelog.Values + changelog.Values = nil + + return &changelog, nil +} + +// ChangeLogIterator cursor within paginated results from the /search endpoint +type ChangeLogIterator struct { + client *Client + idOrKey string + message *ChangeLogPage + Err error + + pageSize int + itemIdx int +} + +// HasError returns true if the iterator is holding an error +func (cli *ChangeLogIterator) HasError() bool { + if cli.Err == errDone { + return false + } + if cli.Err == nil { + return false + } + return true +} + +// HasNext returns true if there is another item available in the result set +func (cli *ChangeLogIterator) HasNext() bool { + return cli.Err == nil && cli.itemIdx < len(cli.message.Entries) +} + +// Next Return the next item in the result set and advance the iterator. +// Advancing the iterator may require fetching a new page. +func (cli *ChangeLogIterator) Next() *ChangeLogEntry { + if cli.Err != nil { + return nil + } + + item := cli.message.Entries[cli.itemIdx] + if cli.itemIdx+1 < len(cli.message.Entries) { + // We still have an item left in the currently cached page + cli.itemIdx++ + } else { + if cli.message.IsLastPage() { + cli.Err = errDone + } else { + // There are still more pages to fetch, so fetch the next page and + // cache it + cli.message, cli.Err = cli.client.GetChangeLog( + cli.idOrKey, cli.pageSize, cli.message.NextStartAt()) + // NOTE(josh): we don't deal with the error now, we just cache it. + // HasNext() will return false and the caller can check the error + // afterward. + cli.itemIdx = 0 + } + } + return &item +} + +// IterChangeLog returns an iterator over entries in the changelog for an issue +func (client *Client) IterChangeLog(idOrKey string, pageSize int) *ChangeLogIterator { + message, err := client.GetChangeLog(idOrKey, pageSize, 0) + + iter := &ChangeLogIterator{ + client: client, + idOrKey: idOrKey, + message: message, + Err: err, + pageSize: pageSize, + itemIdx: 0, + } + + return iter +} + +// GetProject returns the project JSON object given its id or key +func (client *Client) GetProject(projectIDOrKey string) (*Project, error) { + url := fmt.Sprintf( + "%s/rest/api/2/project/%s", client.serverURL, projectIDOrKey) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + return nil, err + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, url) + return nil, err + } + + var project Project + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &project) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &project, nil +} + +// CreateIssue creates a new JIRA issue and returns it +func (client *Client) CreateIssue(projectIDOrKey, title, body string, + extra map[string]interface{}) (*IssueCreateResult, error) { + + url := fmt.Sprintf("%s/rest/api/2/issue", client.serverURL) + + fields := make(map[string]interface{}) + fields["summary"] = title + fields["description"] = body + for key, value := range extra { + fields[key] = value + } + + // If the project string is an integer than assume it is an ID. Otherwise it + // is a key. + _, err := strconv.Atoi(projectIDOrKey) + if err == nil { + fields["project"] = map[string]string{"id": projectIDOrKey} + } else { + fields["project"] = map[string]string{"key": projectIDOrKey} + } + + message := make(map[string]interface{}) + message["fields"] = fields + + data, err := json.Marshal(message) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result IssueCreateResult + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateIssueTitle changes the "summary" field of a JIRA issue +func (client *Client) UpdateIssueTitle(issueKeyOrID, title string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(title) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"summary":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// UpdateIssueBody changes the "description" field of a JIRA issue +func (client *Client) UpdateIssueBody(issueKeyOrID, body string) (time.Time, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s", client.serverURL, issueKeyOrID) + var responseTime time.Time + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + data, err := json.Marshal(body) + if err != nil { + return responseTime, err + } + + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"description":[`) + _, _ = fmt.Fprintf(&buffer, `{"set":%s}`, data) + _, _ = fmt.Fprintf(&buffer, `]}}`) + + data = buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// AddComment adds a new comment to an issue (and returns it). +func (client *Client) AddComment(issueKeyOrID, body string) (*Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment", client.serverURL, issueKeyOrID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusCreated { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateComment changes the text of a comment +func (client *Client) UpdateComment(issueKeyOrID, commentID, body string) ( + *Comment, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/comment/%s", client.serverURL, issueKeyOrID, + commentID) + + params := CommentCreate{Body: body} + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var result Comment + + data, _ = ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &result) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &result, nil +} + +// UpdateLabels changes labels for an issue +func (client *Client) UpdateLabels(issueKeyOrID string, added, removed []bug.Label) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // NOTE(josh): Since updates are a list of heterogeneous objects let's just + // manually build the JSON text + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, `{"update":{"labels":[`) + first := true + for _, label := range added { + if !first { + _, _ = fmt.Fprintf(&buffer, ",") + } + _, _ = fmt.Fprintf(&buffer, `{"add":"%s"}`, label) + first = false + } + for _, label := range removed { + if !first { + _, _ = fmt.Fprintf(&buffer, ",") + } + _, _ = fmt.Fprintf(&buffer, `{"remove":"%s"}`, label) + first = false + } + _, _ = fmt.Fprintf(&buffer, "]}}") + + data := buffer.Bytes() + request, err := http.NewRequest("PUT", url, bytes.NewBuffer(data)) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + content, _ := ioutil.ReadAll(response.Body) + err := fmt.Errorf( + "HTTP response %d, query was %s\n data: %s\n response: %s", + response.StatusCode, request.URL.String(), data, content) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetTransitions returns a list of available transitions for an issue +func (client *Client) GetTransitions(issueKeyOrID string) (*TransitionList, error) { + + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message TransitionList + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +func getTransitionTo(tlist *TransitionList, desiredStateNameOrID string) *Transition { + for _, transition := range tlist.Transitions { + if transition.To.ID == desiredStateNameOrID { + return &transition + } else if transition.To.Name == desiredStateNameOrID { + return &transition + } + } + return nil +} + +// DoTransition changes the "status" of an issue +func (client *Client) DoTransition(issueKeyOrID string, transitionID string) (time.Time, error) { + url := fmt.Sprintf( + "%s/rest/api/2/issue/%s/transitions", client.serverURL, issueKeyOrID) + var responseTime time.Time + + // TODO(josh)[767ee72]: Figure out a good way to "configure" the + // open/close state mapping. It would be *great* if we could actually + // *compute* the necessary transitions and prompt for missing metatdata... + // but that is complex + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, + `{"transition":{"id":"%s"}, "resolution": {"name": "Done"}}`, + transitionID) + request, err := http.NewRequest("POST", url, bytes.NewBuffer(buffer.Bytes())) + if err != nil { + return responseTime, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return responseTime, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusNoContent { + err := errors.Wrap(errTransitionNotAllowed, fmt.Sprintf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String())) + return responseTime, err + } + + dateHeader, ok := response.Header["Date"] + if !ok || len(dateHeader) != 1 { + // No "Date" header, or empty, or multiple of them. Regardless, we don't + // have a date we can return + return responseTime, nil + } + + responseTime, err = http.ParseTime(dateHeader[0]) + if err != nil { + return time.Time{}, err + } + + return responseTime, nil +} + +// GetServerInfo Fetch server information from the /serverinfo endpoint +// https://docs.atlassian.com/software/jira/docs/api/REST/8.2.6/#api/2/issue +func (client *Client) GetServerInfo() (*ServerInfo, error) { + url := fmt.Sprintf("%s/rest/api/2/serverinfo", client.serverURL) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + err := fmt.Errorf("Creating request %v", err) + return nil, err + } + + if client.ctx != nil { + ctx, cancel := context.WithTimeout(client.ctx, defaultTimeout) + defer cancel() + request = request.WithContext(ctx) + } + + response, err := client.Do(request) + if err != nil { + err := fmt.Errorf("Performing request %v", err) + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + err := fmt.Errorf( + "HTTP response %d, query was %s", response.StatusCode, + request.URL.String()) + return nil, err + } + + var message ServerInfo + + data, _ := ioutil.ReadAll(response.Body) + err = json.Unmarshal(data, &message) + if err != nil { + err := fmt.Errorf("Decoding response %v", err) + return nil, err + } + + return &message, nil +} + +// GetServerTime returns the current time on the server +func (client *Client) GetServerTime() (Time, error) { + var result Time + info, err := client.GetServerInfo() + if err != nil { + return result, err + } + return info.ServerTime, nil +} diff --git a/bridge/jira/config.go b/bridge/jira/config.go new file mode 100644 index 00000000..79fd8507 --- /dev/null +++ b/bridge/jira/config.go @@ -0,0 +1,192 @@ +package jira + +import ( + "context" + "fmt" + + "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/input" + "github.com/MichaelMure/git-bug/repository" +) + +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 +see the notes at: +https://github.com/MichaelMure/git-bug/blob/master/doc/jira_bridge.md +` + +const credTypeText = ` +JIRA has recently altered it's authentication strategies. Servers deployed +prior to October 1st 2019 must use "SESSION" authentication, whereby the REST +client logs in with an actual username and password, is assigned a session, and +passes the session cookie with each request. JIRA Cloud and servers deployed +after October 1st 2019 must use "TOKEN" authentication. You must create a user +API token and the client will provide this along with your username with each +request.` + +func (*Jira) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "BaseURL": nil, + "Login": nil, + "CredPrefix": nil, + "Project": nil, + } +} + +// Configure sets up the bridge configuration +func (j *Jira) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { + var err error + + baseURL := params.BaseURL + if baseURL == "" { + // terminal prompt + baseURL, err = input.Prompt("JIRA server URL", "URL", input.Required, input.IsURL) + if err != nil { + return nil, err + } + } + + project := params.Project + if project == "" { + project, err = input.Prompt("JIRA project key", "project", input.Required) + if err != nil { + return nil, err + } + } + + fmt.Println(credTypeText) + credTypeInput, err := input.PromptChoice("Authentication mechanism", []string{"SESSION", "TOKEN"}) + if err != nil { + return nil, err + } + credType := []string{"SESSION", "TOKEN"}[credTypeInput] + + var login string + var cred auth.Credential + + switch { + case params.CredPrefix != "": + cred, err = auth.LoadWithPrefix(repo, params.CredPrefix) + if err != nil { + return nil, err + } + l, ok := cred.GetMetadata(auth.MetaKeyLogin) + if !ok { + return nil, fmt.Errorf("credential doesn't have a login") + } + login = l + default: + login := params.Login + if login == "" { + // TODO: validate username + login, err = input.Prompt("JIRA login", "login", input.Required) + if err != nil { + return nil, err + } + } + cred, err = promptCredOptions(repo, login, baseURL) + if err != nil { + return nil, err + } + } + + conf := make(core.Configuration) + conf[core.ConfigKeyTarget] = target + conf[confKeyBaseUrl] = baseURL + conf[confKeyProject] = project + conf[confKeyCredentialType] = credType + + err = j.ValidateConfig(conf) + if err != nil { + return nil, err + } + + fmt.Printf("Attempting to login with credentials...\n") + client, err := buildClient(context.TODO(), baseURL, credType, cred) + if err != nil { + return nil, err + } + + // verify access to the project with credentials + fmt.Printf("Checking project ...\n") + _, err = client.GetProject(project) + if err != nil { + return nil, fmt.Errorf( + "Project %s doesn't exist on %s, or authentication credentials for (%s)"+ + " are invalid", + project, baseURL, login) + } + + // don't forget to store the now known valid token + if !auth.IdExist(repo, cred.ID()) { + err = auth.Store(repo, cred) + if err != nil { + return nil, err + } + } + + err = core.FinishConfig(repo, metaKeyJiraLogin, login) + if err != nil { + return nil, err + } + + fmt.Print(moreConfigText) + return conf, nil +} + +// ValidateConfig returns true if all required keys are present +func (*Jira) ValidateConfig(conf core.Configuration) error { + if v, ok := conf[core.ConfigKeyTarget]; !ok { + return fmt.Errorf("missing %s key", core.ConfigKeyTarget) + } else if v != target { + return fmt.Errorf("unexpected target name: %v", v) + } + + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) + } + + return nil +} + +func promptCredOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindToken), + auth.WithMeta(auth.MetaKeyLogin, login), + auth.WithMeta(auth.MetaKeyBaseURL, baseUrl), + ) + if err != nil { + return nil, err + } + + cred, index, err := input.PromptCredential(target, "password", creds, []string{ + "enter my password", + "ask my password each time", + }) + switch { + case err != nil: + return nil, err + case cred != nil: + return cred, nil + case index == 0: + password, err := input.PromptPassword("Password", "password", input.Required) + if err != nil { + return nil, err + } + lp := auth.NewLoginPassword(target, login, password) + lp.SetMetadata(auth.MetaKeyLogin, login) + lp.SetMetadata(auth.MetaKeyBaseURL, baseUrl) + return lp, nil + case index == 1: + l := auth.NewLogin(target, login) + l.SetMetadata(auth.MetaKeyLogin, login) + l.SetMetadata(auth.MetaKeyBaseURL, baseUrl) + return l, nil + default: + panic("missed case") + } +} diff --git a/bridge/jira/export.go b/bridge/jira/export.go new file mode 100644 index 00000000..37066263 --- /dev/null +++ b/bridge/jira/export.go @@ -0,0 +1,475 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/identity" +) + +var ( + ErrMissingCredentials = errors.New("missing credentials") +) + +// jiraExporter implement the Exporter interface +type jiraExporter struct { + conf core.Configuration + + // cache identities clients + identityClient map[entity.Id]*Client + + // the mapping from git-bug "status" to JIRA "status" id + statusMap map[string]string + + // cache identifiers used to speed up exporting operations + // cleared for each bug + cachedOperationIDs map[entity.Id]string + + // cache labels used to speed up exporting labels events + cachedLabels map[string]string + + // store JIRA project information + project *Project +} + +// Init . +func (je *jiraExporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error { + je.conf = conf + je.identityClient = make(map[entity.Id]*Client) + je.cachedOperationIDs = make(map[entity.Id]string) + je.cachedLabels = make(map[string]string) + + statusMap, err := getStatusMap(je.conf) + if err != nil { + return err + } + je.statusMap = statusMap + + // preload all clients + err = je.cacheAllClient(ctx, repo) + if err != nil { + return err + } + + if len(je.identityClient) == 0 { + return fmt.Errorf("no credentials for this bridge") + } + + var client *Client + for _, c := range je.identityClient { + client = c + break + } + + if client == nil { + panic("nil client") + } + + je.project, err = client.GetProject(je.conf[confKeyProject]) + if err != nil { + return err + } + + return nil +} + +func (je *jiraExporter) cacheAllClient(ctx context.Context, repo *cache.RepoCache) error { + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithKind(auth.KindLoginPassword), auth.WithKind(auth.KindLogin), + auth.WithMeta(auth.MetaKeyBaseURL, je.conf[confKeyBaseUrl]), + ) + if err != nil { + return err + } + + for _, cred := range creds { + login, ok := cred.GetMetadata(auth.MetaKeyLogin) + if !ok { + _, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Jira login\n", cred.ID().Human()) + continue + } + + user, err := repo.ResolveIdentityImmutableMetadata(metaKeyJiraLogin, login) + if err == identity.ErrIdentityNotExist { + continue + } + if err != nil { + return nil + } + + if _, ok := je.identityClient[user.Id()]; !ok { + client, err := buildClient(ctx, je.conf[confKeyBaseUrl], je.conf[confKeyCredentialType], creds[0]) + if err != nil { + return err + } + je.identityClient[user.Id()] = client + } + } + + return nil +} + +// getClientForIdentity return an API client configured with the credentials +// of the given identity. If no client were found it will initialize it from +// the known credentials and cache it for next use. +func (je *jiraExporter) getClientForIdentity(userId entity.Id) (*Client, error) { + client, ok := je.identityClient[userId] + if ok { + return client, nil + } + + return nil, ErrMissingCredentials +} + +// ExportAll export all event made by the current user to Jira +func (je *jiraExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) { + out := make(chan core.ExportResult) + + go func() { + defer close(out) + + var allIdentitiesIds []entity.Id + for id := range je.identityClient { + allIdentitiesIds = append(allIdentitiesIds, id) + } + + allBugsIds := repo.AllBugsIds() + + for _, id := range allBugsIds { + b, err := repo.ResolveBug(id) + if err != nil { + out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id) + return + } + + select { + + case <-ctx.Done(): + // stop iterating if context cancel function is called + return + + default: + snapshot := b.Snapshot() + + // ignore issues whose last modification date is before the query date + // TODO: compare the Lamport time instead of using the unix time + if snapshot.CreatedAt.Before(since) { + out <- core.NewExportNothing(b.Id(), "bug created before the since date") + continue + } + + if snapshot.HasAnyActor(allIdentitiesIds...) { + // try to export the bug and it associated events + err := je.exportBug(ctx, b, out) + if err != nil { + out <- core.NewExportError(errors.Wrap(err, "can't export bug"), id) + return + } + } else { + out <- core.NewExportNothing(id, "not an actor") + } + } + } + }() + + return out, nil +} + +// exportBug publish bugs and related events +func (je *jiraExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) error { + snapshot := b.Snapshot() + + var bugJiraID string + + // Special case: + // if a user try to export a bug that is not already exported to jira (or + // imported from jira) and we do not have the token of the bug author, + // there is nothing we can do. + + // first operation is always createOp + createOp := snapshot.Operations[0].(*bug.CreateOperation) + author := snapshot.Author + + // skip bug if it was imported from some other bug system + origin, ok := snapshot.GetCreateMetadata(core.MetaKeyOrigin) + if ok && origin != target { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin)) + return nil + } + + // skip bug if it is a jira bug but is associated with another project + // (one bridge per JIRA project) + project, ok := snapshot.GetCreateMetadata(metaKeyJiraProject) + if ok && !stringInSlice(project, []string{je.project.ID, je.project.Key}) { + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("issue tagged with project: %s", project)) + return nil + } + + // get jira bug ID + jiraID, ok := snapshot.GetCreateMetadata(metaKeyJiraId) + if ok { + // will be used to mark operation related to a bug as exported + bugJiraID = jiraID + } else { + // check that we have credentials for operation author + client, err := je.getClientForIdentity(author.Id()) + if err != nil { + // if bug is not yet exported and we do not have the author's credentials + // then there is nothing we can do, so just skip this bug + out <- core.NewExportNothing( + b.Id(), fmt.Sprintf("missing author credentials for user %.8s", + author.Id().String())) + return err + } + + // Load any custom fields required to create an issue from the git + // config file. + fields := make(map[string]interface{}) + defaultFields, hasConf := je.conf[confKeyCreateDefaults] + if hasConf { + err = json.Unmarshal([]byte(defaultFields), &fields) + if err != nil { + return err + } + } else { + // If there is no configuration provided, at the very least the + // "issueType" field is always required. 10001 is "story" which I'm + // pretty sure is standard/default on all JIRA instances. + fields["issuetype"] = map[string]interface{}{ + "id": "10001", + } + } + bugIDField, hasConf := je.conf[confKeyCreateGitBug] + if hasConf { + // If the git configuration also indicates it, we can assign the git-bug + // id to a custom field to assist in integrations + fields[bugIDField] = b.Id().String() + } + + // create bug + result, err := client.CreateIssue( + je.project.ID, createOp.Title, createOp.Message, fields) + if err != nil { + err := errors.Wrap(err, "exporting jira issue") + out <- core.NewExportError(err, b.Id()) + return err + } + + id := result.ID + out <- core.NewExportBug(b.Id()) + // mark bug creation operation as exported + err = markOperationAsExported( + b, createOp.Id(), id, je.project.Key, time.Time{}) + if err != nil { + err := errors.Wrap(err, "marking operation as exported") + out <- core.NewExportError(err, b.Id()) + return err + } + + // commit operation to avoid creating multiple issues with multiple pushes + err = b.CommitAsNeeded() + if err != nil { + err := errors.Wrap(err, "bug commit") + out <- core.NewExportError(err, b.Id()) + return err + } + + // cache bug jira ID + bugJiraID = id + } + + // cache operation jira id + je.cachedOperationIDs[createOp.Id()] = bugJiraID + + for _, op := range snapshot.Operations[1:] { + // ignore SetMetadata operations + if _, ok := op.(*bug.SetMetadataOperation); ok { + continue + } + + // ignore operations already existing in jira (due to import or export) + // cache the ID of already exported or imported issues and events from + // Jira + if id, ok := op.GetMetadata(metaKeyJiraId); ok { + je.cachedOperationIDs[op.Id()] = id + continue + } + + opAuthor := op.GetAuthor() + client, err := je.getClientForIdentity(opAuthor.Id()) + if err != nil { + out <- core.NewExportError( + fmt.Errorf("missing operation author credentials for user %.8s", + author.Id().String()), op.Id()) + continue + } + + var id string + var exportTime time.Time + switch opr := op.(type) { + case *bug.AddCommentOperation: + comment, err := client.AddComment(bugJiraID, opr.Message) + if err != nil { + err := errors.Wrap(err, "adding comment") + out <- core.NewExportError(err, b.Id()) + return err + } + id = comment.ID + out <- core.NewExportComment(op.Id()) + + // cache comment id + je.cachedOperationIDs[op.Id()] = id + + case *bug.EditCommentOperation: + if opr.Target == createOp.Id() { + // An EditCommentOpreation with the Target set to the create operation + // encodes a modification to the long-description/summary. + exportTime, err = client.UpdateIssueBody(bugJiraID, opr.Message) + if err != nil { + err := errors.Wrap(err, "editing issue") + out <- core.NewExportError(err, b.Id()) + return err + } + out <- core.NewExportCommentEdition(op.Id()) + id = bugJiraID + } else { + // Otherwise it's an edit to an actual comment. A comment cannot be + // edited before it was created, so it must be the case that we have + // already observed and cached the AddCommentOperation. + commentID, ok := je.cachedOperationIDs[opr.Target] + if !ok { + // Since an edit has to come after the creation, we expect we would + // have cached the creation id. + panic("unexpected error: comment id not found") + } + comment, err := client.UpdateComment(bugJiraID, commentID, opr.Message) + if err != nil { + err := errors.Wrap(err, "editing comment") + out <- core.NewExportError(err, b.Id()) + return err + } + out <- core.NewExportCommentEdition(op.Id()) + // JIRA doesn't track all comment edits, they will only tell us about + // the most recent one. We must invent a consistent id for the operation + // so we use the comment ID plus the timestamp of the update, as + // reported by JIRA. Note that this must be consistent with the importer + // during ensureComment() + id = getTimeDerivedID(comment.ID, comment.Updated) + } + + case *bug.SetStatusOperation: + jiraStatus, hasStatus := je.statusMap[opr.Status.String()] + if hasStatus { + exportTime, err = UpdateIssueStatus(client, bugJiraID, jiraStatus) + if err != nil { + err := errors.Wrap(err, "editing status") + out <- core.NewExportWarning(err, b.Id()) + // Failure to update status isn't necessarily a big error. It's + // possible that we just don't have enough information to make that + // update. In this case, just don't export the operation. + continue + } + out <- core.NewExportStatusChange(op.Id()) + id = bugJiraID + } else { + out <- core.NewExportError(fmt.Errorf( + "No jira status mapped for %.8s", opr.Status.String()), b.Id()) + } + + case *bug.SetTitleOperation: + exportTime, err = client.UpdateIssueTitle(bugJiraID, opr.Title) + if err != nil { + err := errors.Wrap(err, "editing title") + out <- core.NewExportError(err, b.Id()) + return err + } + out <- core.NewExportTitleEdition(op.Id()) + id = bugJiraID + + case *bug.LabelChangeOperation: + exportTime, err = client.UpdateLabels( + bugJiraID, opr.Added, opr.Removed) + if err != nil { + err := errors.Wrap(err, "updating labels") + out <- core.NewExportError(err, b.Id()) + return err + } + out <- core.NewExportLabelChange(op.Id()) + id = bugJiraID + + default: + panic("unhandled operation type case") + } + + // mark operation as exported + err = markOperationAsExported( + b, op.Id(), id, je.project.Key, exportTime) + if err != nil { + err := errors.Wrap(err, "marking operation as exported") + out <- core.NewExportError(err, b.Id()) + return err + } + + // commit at each operation export to avoid exporting same events multiple + // times + err = b.CommitAsNeeded() + if err != nil { + err := errors.Wrap(err, "bug commit") + out <- core.NewExportError(err, b.Id()) + return err + } + } + + return nil +} + +func markOperationAsExported(b *cache.BugCache, target entity.Id, jiraID, jiraProject string, exportTime time.Time) error { + newMetadata := map[string]string{ + metaKeyJiraId: jiraID, + metaKeyJiraProject: jiraProject, + } + if !exportTime.IsZero() { + newMetadata[metaKeyJiraExportTime] = exportTime.Format(http.TimeFormat) + } + + _, err := b.SetMetadata(target, newMetadata) + return err +} + +// UpdateIssueStatus attempts to change the "status" field by finding a +// transition which achieves the desired state and then performing that +// transition +func UpdateIssueStatus(client *Client, issueKeyOrID string, desiredStateNameOrID string) (time.Time, error) { + var responseTime time.Time + + tlist, err := client.GetTransitions(issueKeyOrID) + if err != nil { + return responseTime, err + } + + transition := getTransitionTo(tlist, desiredStateNameOrID) + if transition == nil { + return responseTime, errTransitionNotFound + } + + responseTime, err = client.DoTransition(issueKeyOrID, transition.ID) + if err != nil { + return responseTime, err + } + + return responseTime, nil +} diff --git a/bridge/jira/import.go b/bridge/jira/import.go new file mode 100644 index 00000000..f35f114f --- /dev/null +++ b/bridge/jira/import.go @@ -0,0 +1,655 @@ +package jira + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/entity" + "github.com/MichaelMure/git-bug/util/text" +) + +const ( + defaultPageSize = 10 +) + +// jiraImporter implement the Importer interface +type jiraImporter struct { + conf core.Configuration + + client *Client + + // send only channel + out chan<- core.ImportResult +} + +// Init . +func (ji *jiraImporter) Init(ctx context.Context, repo *cache.RepoCache, conf core.Configuration) error { + ji.conf = conf + + var cred auth.Credential + + // Prioritize LoginPassword credentials to avoid a prompt + creds, err := auth.List(repo, + auth.WithTarget(target), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]), + auth.WithKind(auth.KindLoginPassword), + ) + if err != nil { + return err + } + if len(creds) > 0 { + cred = creds[0] + goto end + } + + creds, err = auth.List(repo, + auth.WithTarget(target), + auth.WithMeta(auth.MetaKeyBaseURL, conf[confKeyBaseUrl]), + auth.WithKind(auth.KindLogin), + ) + if err != nil { + return err + } + if len(creds) > 0 { + cred = creds[0] + } + +end: + if cred == nil { + return fmt.Errorf("no credential for this bridge") + } + + // TODO(josh)[da52062]: Validate token and if it is expired then prompt for + // credentials and generate a new one + ji.client, err = buildClient(ctx, conf[confKeyBaseUrl], conf[confKeyCredentialType], cred) + return err +} + +// ImportAll iterate over all the configured repository issues and ensure the +// creation of the missing issues / timeline items / edits / label events ... +func (ji *jiraImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) { + sinceStr := since.Format("2006-01-02 15:04") + project := ji.conf[confKeyProject] + + out := make(chan core.ImportResult) + ji.out = out + + go func() { + defer close(ji.out) + + message, err := ji.client.Search( + fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr), 0, 0) + if err != nil { + out <- core.NewImportError(err, "") + return + } + + fmt.Printf("So far so good. Have %d issues to import\n", message.Total) + + jql := fmt.Sprintf("project=%s AND updatedDate>\"%s\"", project, sinceStr) + var searchIter *SearchIterator + for searchIter = + ji.client.IterSearch(jql, defaultPageSize); searchIter.HasNext(); { + issue := searchIter.Next() + b, err := ji.ensureIssue(repo, *issue) + if err != nil { + err := fmt.Errorf("issue creation: %v", err) + out <- core.NewImportError(err, "") + return + } + + var commentIter *CommentIterator + for commentIter = + ji.client.IterComments(issue.ID, defaultPageSize); commentIter.HasNext(); { + comment := commentIter.Next() + err := ji.ensureComment(repo, b, *comment) + if err != nil { + out <- core.NewImportError(err, "") + } + } + if commentIter.HasError() { + out <- core.NewImportError(commentIter.Err, "") + } + + snapshot := b.Snapshot() + opIdx := 0 + + var changelogIter *ChangeLogIterator + for changelogIter = + ji.client.IterChangeLog(issue.ID, defaultPageSize); changelogIter.HasNext(); { + changelogEntry := changelogIter.Next() + + // Advance the operation iterator up to the first operation which has + // an export date not before the changelog entry date. If the changelog + // entry was created in response to an exported operation, then this + // will be that operation. + var exportTime time.Time + for ; opIdx < len(snapshot.Operations); opIdx++ { + exportTimeStr, hasTime := snapshot.Operations[opIdx].GetMetadata( + metaKeyJiraExportTime) + if !hasTime { + continue + } + exportTime, err = http.ParseTime(exportTimeStr) + if err != nil { + continue + } + if !exportTime.Before(changelogEntry.Created.Time) { + break + } + } + if opIdx < len(snapshot.Operations) { + err = ji.ensureChange(repo, b, *changelogEntry, snapshot.Operations[opIdx]) + } else { + err = ji.ensureChange(repo, b, *changelogEntry, nil) + } + if err != nil { + out <- core.NewImportError(err, "") + } + + } + if changelogIter.HasError() { + out <- core.NewImportError(changelogIter.Err, "") + } + + if !b.NeedCommit() { + out <- core.NewImportNothing(b.Id(), "no imported operation") + } else if err := b.Commit(); err != nil { + err = fmt.Errorf("bug commit: %v", err) + out <- core.NewImportError(err, "") + return + } + } + if searchIter.HasError() { + out <- core.NewImportError(searchIter.Err, "") + } + }() + + return out, nil +} + +// Create a bug.Person from a JIRA user +func (ji *jiraImporter) ensurePerson(repo *cache.RepoCache, user User) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata( + metaKeyJiraUser, string(user.Key)) + if err == nil { + return i, nil + } + if _, ok := err.(entity.ErrMultipleMatch); ok { + return nil, err + } + + i, err = repo.NewIdentityRaw( + user.DisplayName, + user.EmailAddress, + "", + map[string]string{ + metaKeyJiraUser: string(user.Key), + }, + ) + + if err != nil { + return nil, err + } + + ji.out <- core.NewImportIdentity(i.Id()) + return i, nil +} + +// Create a bug.Bug based from a JIRA issue +func (ji *jiraImporter) ensureIssue(repo *cache.RepoCache, issue Issue) (*cache.BugCache, error) { + author, err := ji.ensurePerson(repo, issue.Fields.Creator) + if err != nil { + return nil, err + } + + b, err := repo.ResolveBugCreateMetadata(metaKeyJiraId, issue.ID) + if err != nil && err != bug.ErrBugNotExist { + return nil, err + } + + if err == bug.ErrBugNotExist { + cleanText, err := text.Cleanup(string(issue.Fields.Description)) + if err != nil { + return nil, err + } + + // NOTE(josh): newlines in titles appears to be rare, but it has been seen + // in the wild. It does not appear to be allowed in the JIRA web interface. + title := strings.Replace(issue.Fields.Summary, "\n", "", -1) + b, _, err = repo.NewBugRaw( + author, + issue.Fields.Created.Unix(), + title, + cleanText, + nil, + map[string]string{ + core.MetaKeyOrigin: target, + metaKeyJiraId: issue.ID, + metaKeyJiraKey: issue.Key, + metaKeyJiraProject: ji.conf[confKeyProject], + }) + if err != nil { + return nil, err + } + + ji.out <- core.NewImportBug(b.Id()) + } + + return b, nil +} + +// Return a unique string derived from a unique jira id and a timestamp +func getTimeDerivedID(jiraID string, timestamp Time) string { + return fmt.Sprintf("%s-%d", jiraID, timestamp.Unix()) +} + +// Create a bug.Comment from a JIRA comment +func (ji *jiraImporter) ensureComment(repo *cache.RepoCache, b *cache.BugCache, item Comment) error { + // ensure person + author, err := ji.ensurePerson(repo, item.Author) + if err != nil { + return err + } + + targetOpID, err := b.ResolveOperationWithMetadata( + metaKeyJiraId, item.ID) + if err != nil && err != cache.ErrNoMatchingOp { + return err + } + + // If the comment is a new comment then create it + if targetOpID == "" && err == cache.ErrNoMatchingOp { + var cleanText string + if item.Updated != item.Created { + // We don't know the original text... we only have the updated text. + cleanText = "" + } else { + cleanText, err = text.Cleanup(string(item.Body)) + if err != nil { + return err + } + } + + // add comment operation + op, err := b.AddCommentRaw( + author, + item.Created.Unix(), + cleanText, + nil, + map[string]string{ + metaKeyJiraId: item.ID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportComment(op.Id()) + targetOpID = op.Id() + } + + // If there are no updates to this comment, then we are done + if item.Updated == item.Created { + return nil + } + + // If there has been an update to this comment, we try to find it in the + // database. We need a unique id so we'll concat the issue id with the update + // timestamp. Note that this must be consistent with the exporter during + // export of an EditCommentOperation + derivedID := getTimeDerivedID(item.ID, item.Updated) + _, err = b.ResolveOperationWithMetadata(metaKeyJiraId, derivedID) + if err == nil { + // Already imported this edition + return nil + } + + if err != cache.ErrNoMatchingOp { + return err + } + + // ensure editor identity + editor, err := ji.ensurePerson(repo, item.UpdateAuthor) + if err != nil { + return err + } + + // comment edition + cleanText, err := text.Cleanup(string(item.Body)) + if err != nil { + return err + } + op, err := b.EditCommentRaw( + editor, + item.Updated.Unix(), + targetOpID, + cleanText, + map[string]string{ + metaKeyJiraId: derivedID, + }, + ) + + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + return nil +} + +// Return a unique string derived from a unique jira id and an index into the +// data referred to by that jira id. +func getIndexDerivedID(jiraID string, idx int) string { + return fmt.Sprintf("%s-%d", jiraID, idx) +} + +func labelSetsMatch(jiraSet []string, gitbugSet []bug.Label) bool { + if len(jiraSet) != len(gitbugSet) { + return false + } + + sort.Strings(jiraSet) + gitbugStrSet := make([]string, len(gitbugSet)) + for idx, label := range gitbugSet { + gitbugStrSet[idx] = label.String() + } + sort.Strings(gitbugStrSet) + + for idx, value := range jiraSet { + if value != gitbugStrSet[idx] { + return false + } + } + + return true +} + +// Create a bug.Operation (or a series of operations) from a JIRA changelog +// entry +func (ji *jiraImporter) ensureChange(repo *cache.RepoCache, b *cache.BugCache, entry ChangeLogEntry, potentialOp bug.Operation) error { + + // If we have an operation which is already mapped to the entire changelog + // entry then that means this changelog entry was induced by an export + // operation and we've already done the match, so we skip this one + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, entry.ID) + if err == nil { + return nil + } else if err != cache.ErrNoMatchingOp { + return err + } + + // In general, multiple fields may be changed in changelog entry on + // JIRA. For example, when an issue is closed both its "status" and its + // "resolution" are updated within a single changelog entry. + // I don't thing git-bug has a single operation to modify an arbitrary + // number of fields in one go, so we break up the single JIRA changelog + // entry into individual field updates. + author, err := ji.ensurePerson(repo, entry.Author) + if err != nil { + return err + } + + if len(entry.Items) < 1 { + return fmt.Errorf("Received changelog entry with no item! (%s)", entry.ID) + } + + statusMap, err := getStatusMapReverse(ji.conf) + if err != nil { + return err + } + + // NOTE(josh): first do an initial scan and see if any of the changed items + // matches the current potential operation. If it does, then we know that this + // entire changelog entry was created in response to that git-bug operation. + // So we associate the operation with the entire changelog, and not a specific + // entry. + for _, item := range entry.Items { + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + opr, isRightType := potentialOp.(*bug.LabelChangeOperation) + if isRightType && labelSetsMatch(addedLabels, opr.Added) && labelSetsMatch(removedLabels, opr.Removed) { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "status": + opr, isRightType := potentialOp.(*bug.SetStatusOperation) + if isRightType && statusMap[opr.Status.String()] == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + opr, isRightType := potentialOp.(*bug.SetTitleOperation) + if isRightType && opr.Title == item.To { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + opr, isRightType := potentialOp.(*bug.EditCommentOperation) + if isRightType && + opr.Target == b.Snapshot().Operations[0].Id() && + opr.Message == item.ToString { + _, err := b.SetMetadata(opr.Id(), map[string]string{ + metaKeyJiraDerivedId: entry.ID, + }) + if err != nil { + return err + } + return nil + } + } + } + + // Since we didn't match the changelog entry to a known export operation, + // then this is a changelog entry that we should import. We import each + // changelog entry item as a separate git-bug operation. + for idx, item := range entry.Items { + derivedID := getIndexDerivedID(entry.ID, idx) + _, err := b.ResolveOperationWithMetadata(metaKeyJiraDerivedId, derivedID) + if err == nil { + continue + } + if err != cache.ErrNoMatchingOp { + return err + } + + switch item.Field { + case "labels": + fromLabels := removeEmpty(strings.Split(item.FromString, " ")) + toLabels := removeEmpty(strings.Split(item.ToString, " ")) + removedLabels, addedLabels, _ := setSymmetricDifference(fromLabels, toLabels) + + op, err := b.ForceChangeLabelsRaw( + author, + entry.Created.Unix(), + addedLabels, + removedLabels, + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportLabelChange(op.Id()) + + case "status": + statusStr, hasMap := statusMap[item.To] + if hasMap { + switch statusStr { + case bug.OpenStatus.String(): + op, err := b.OpenRaw( + author, + entry.Created.Unix(), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + + case bug.ClosedStatus.String(): + op, err := b.CloseRaw( + author, + entry.Created.Unix(), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + ji.out <- core.NewImportStatusChange(op.Id()) + } + } else { + ji.out <- core.NewImportError( + fmt.Errorf( + "No git-bug status mapped for jira status %s (%s)", + item.ToString, item.To), "") + } + + case "summary": + // NOTE(josh): JIRA calls it "summary", which sounds more like the body + // text, but it's the title + op, err := b.SetTitleRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportTitleEdition(op.Id()) + + case "description": + // NOTE(josh): JIRA calls it "description", which sounds more like the + // title but it's actually the body + op, err := b.EditCreateCommentRaw( + author, + entry.Created.Unix(), + string(item.ToString), + map[string]string{ + metaKeyJiraId: entry.ID, + metaKeyJiraDerivedId: derivedID, + }, + ) + if err != nil { + return err + } + + ji.out <- core.NewImportCommentEdition(op.Id()) + + default: + ji.out <- core.NewImportWarning( + fmt.Errorf( + "Unhandled changelog event %s", item.Field), "") + } + + // Other Examples: + // "assignee" (jira) + // "Attachment" (jira) + // "Epic Link" (custom) + // "Rank" (custom) + // "resolution" (jira) + // "Sprint" (custom) + } + return nil +} + +func getStatusMap(conf core.Configuration) (map[string]string, error) { + mapStr, hasConf := conf[confKeyIDMap] + if !hasConf { + return map[string]string{ + bug.OpenStatus.String(): "1", + bug.ClosedStatus.String(): "6", + }, nil + } + + statusMap := make(map[string]string) + err := json.Unmarshal([]byte(mapStr), &statusMap) + return statusMap, err +} + +func getStatusMapReverse(conf core.Configuration) (map[string]string, error) { + fwdMap, err := getStatusMap(conf) + if err != nil { + return fwdMap, err + } + + outMap := map[string]string{} + for key, val := range fwdMap { + outMap[val] = key + } + + mapStr, hasConf := conf[confKeyIDRevMap] + if !hasConf { + return outMap, nil + } + + revMap := make(map[string]string) + err = json.Unmarshal([]byte(mapStr), &revMap) + for key, val := range revMap { + outMap[key] = val + } + + return outMap, err +} + +func removeEmpty(values []string) []string { + output := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + output = append(output, value) + } + } + return output +} diff --git a/bridge/jira/jira.go b/bridge/jira/jira.go new file mode 100644 index 00000000..b891ee3d --- /dev/null +++ b/bridge/jira/jira.go @@ -0,0 +1,143 @@ +// Package jira contains the Jira bridge implementation +package jira + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/input" +) + +const ( + target = "jira" + + metaKeyJiraId = "jira-id" + metaKeyJiraDerivedId = "jira-derived-id" + metaKeyJiraKey = "jira-key" + metaKeyJiraUser = "jira-user" + metaKeyJiraProject = "jira-project" + metaKeyJiraExportTime = "jira-export-time" + metaKeyJiraLogin = "jira-login" + + confKeyBaseUrl = "base-url" + confKeyProject = "project" + confKeyCredentialType = "credentials-type" // "SESSION" or "TOKEN" + confKeyIDMap = "bug-id-map" + confKeyIDRevMap = "bug-id-revmap" + // the issue type when exporting a new bug. Default is Story (10001) + confKeyCreateDefaults = "create-issue-defaults" + // if set, the bridge fill this JIRA field with the `git-bug` id when exporting + confKeyCreateGitBug = "create-issue-gitbug-id" + + defaultTimeout = 60 * time.Second +) + +var _ core.BridgeImpl = &Jira{} + +// Jira Main object for the bridge +type Jira struct{} + +// Target returns "jira" +func (*Jira) Target() string { + return target +} + +func (*Jira) LoginMetaKey() string { + return metaKeyJiraLogin +} + +// NewImporter returns the jira importer +func (*Jira) NewImporter() core.Importer { + return &jiraImporter{} +} + +// NewExporter returns the jira exporter +func (*Jira) NewExporter() core.Exporter { + return &jiraExporter{} +} + +func buildClient(ctx context.Context, baseURL string, credType string, cred auth.Credential) (*Client, error) { + client := NewClient(ctx, baseURL) + + var login, password string + + switch cred := cred.(type) { + case *auth.LoginPassword: + login = cred.Login + password = cred.Password + case *auth.Login: + login = cred.Login + p, err := input.PromptPassword(fmt.Sprintf("Password for %s", login), "password", input.Required) + if err != nil { + return nil, err + } + password = p + } + + err := client.Login(credType, login, password) + if err != nil { + return nil, err + } + + return client, nil +} + +// stringInSlice returns true if needle is found in haystack +func stringInSlice(needle string, haystack []string) bool { + for _, match := range haystack { + if match == needle { + return true + } + } + return false +} + +// Given two string slices, return three lists containing: +// 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) { + sort.Strings(setA) + sort.Strings(setB) + + maxLen := len(setA) + len(setB) + onlyA := make([]string, 0, maxLen) + onlyB := make([]string, 0, maxLen) + both := make([]string, 0, maxLen) + + idxA := 0 + idxB := 0 + + for idxA < len(setA) && idxB < len(setB) { + if setA[idxA] < setB[idxB] { + // In the first set, but not the second + onlyA = append(onlyA, setA[idxA]) + idxA++ + } else if setA[idxA] > setB[idxB] { + // In the second set, but not the first + onlyB = append(onlyB, setB[idxB]) + idxB++ + } else { + // In both + both = append(both, setA[idxA]) + idxA++ + idxB++ + } + } + + for ; idxA < len(setA); idxA++ { + // Leftovers in the first set, not the second + onlyA = append(onlyA, setA[idxA]) + } + + for ; idxB < len(setB); idxB++ { + // Leftovers in the second set, not the first + onlyB = append(onlyB, setB[idxB]) + } + + return onlyA, onlyB, both +} diff --git a/bridge/launchpad/config.go b/bridge/launchpad/config.go index e029fad3..dfff0d3d 100644 --- a/bridge/launchpad/config.go +++ b/bridge/launchpad/config.go @@ -13,18 +13,14 @@ import ( var ErrBadProjectURL = errors.New("bad Launchpad project URL") -func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { - if params.TokenRaw != "" { - fmt.Println("warning: token params are ineffective for a Launchpad bridge") - } - if params.Owner != "" { - fmt.Println("warning: --owner is ineffective for a Launchpad bridge") - } - if params.BaseURL != "" { - fmt.Println("warning: --base-url is ineffective for a Launchpad bridge") +func (Launchpad) ValidParams() map[string]interface{} { + return map[string]interface{}{ + "URL": nil, + "Project": nil, } +} - conf := make(core.Configuration) +func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) { var err error var project string @@ -52,8 +48,9 @@ func (l *Launchpad) Configure(repo *cache.RepoCache, params core.BridgeParams) ( return nil, fmt.Errorf("project doesn't exist") } + conf := make(core.Configuration) conf[core.ConfigKeyTarget] = target - conf[keyProject] = project + conf[confKeyProject] = project err = l.ValidateConfig(conf) if err != nil { @@ -70,8 +67,8 @@ func (*Launchpad) ValidateConfig(conf core.Configuration) error { return fmt.Errorf("unexpected target name: %v", v) } - if _, ok := conf[keyProject]; !ok { - return fmt.Errorf("missing %s key", keyProject) + if _, ok := conf[confKeyProject]; !ok { + return fmt.Errorf("missing %s key", confKeyProject) } return nil @@ -94,10 +91,7 @@ func validateProject(project string) (bool, error) { // extract project name from url func splitURL(url string) (string, error) { - re, err := regexp.Compile(`launchpad\.net[\/:]([^\/]*[a-z]+)`) - if err != nil { - panic("regexp compile:" + err.Error()) - } + re := regexp.MustCompile(`launchpad\.net[\/:]([^\/]*[a-z]+)`) res := re.FindStringSubmatch(url) if res == nil { diff --git a/bridge/launchpad/import.go b/bridge/launchpad/import.go index 5bca8e63..dfcbb95e 100644 --- a/bridge/launchpad/import.go +++ b/bridge/launchpad/import.go @@ -15,7 +15,7 @@ type launchpadImporter struct { conf core.Configuration } -func (li *launchpadImporter) Init(repo *cache.RepoCache, conf core.Configuration) error { +func (li *launchpadImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error { li.conf = conf return nil } diff --git a/bridge/launchpad/launchpad.go b/bridge/launchpad/launchpad.go index b4fcdd00..51ee79d2 100644 --- a/bridge/launchpad/launchpad.go +++ b/bridge/launchpad/launchpad.go @@ -13,7 +13,7 @@ const ( metaKeyLaunchpadID = "launchpad-id" metaKeyLaunchpadLogin = "launchpad-login" - keyProject = "project" + confKeyProject = "project" defaultTimeout = 60 * time.Second ) @@ -26,7 +26,7 @@ func (*Launchpad) Target() string { return "launchpad-preview" } -func (l *Launchpad) LoginMetaKey() string { +func (Launchpad) LoginMetaKey() string { return metaKeyLaunchpadLogin } diff --git a/bug/op_edit_comment.go b/bug/op_edit_comment.go index 44ee5877..f82e7590 100644 --- a/bug/op_edit_comment.go +++ b/bug/op_edit_comment.go @@ -156,3 +156,15 @@ func EditCommentWithFiles(b Interface, author identity.Interface, unixTime int64 b.Append(editCommentOp) return editCommentOp, nil } + +// Convenience function to edit the body of a bug (the first comment) +func EditCreateComment(b Interface, author identity.Interface, unixTime int64, message string) (*EditCommentOperation, error) { + createOp := b.FirstOp().(*CreateOperation) + return EditComment(b, author, unixTime, createOp.Id(), message) +} + +// Convenience function to edit the body of a bug (the first comment) +func EditCreateCommentWithFiles(b Interface, author identity.Interface, unixTime int64, message string, files []git.Hash) (*EditCommentOperation, error) { + createOp := b.FirstOp().(*CreateOperation) + return EditCommentWithFiles(b, author, unixTime, createOp.Id(), message, files) +} diff --git a/cache/bug_cache.go b/cache/bug_cache.go index 6026190f..b86b31e0 100644 --- a/cache/bug_cache.go +++ b/cache/bug_cache.go @@ -210,6 +210,28 @@ func (c *BugCache) SetTitleRaw(author *IdentityCache, unixTime int64, title stri return op, c.notifyUpdated() } +func (c *BugCache) EditCreateComment(body string) (*bug.EditCommentOperation, error) { + author, err := c.repoCache.GetUserIdentity() + if err != nil { + return nil, err + } + + return c.EditCreateCommentRaw(author, time.Now().Unix(), body, nil) +} + +func (c *BugCache) EditCreateCommentRaw(author *IdentityCache, unixTime int64, body string, metadata map[string]string) (*bug.EditCommentOperation, error) { + op, err := bug.EditCreateComment(c.bug, author.Identity, unixTime, body) + if err != nil { + return nil, err + } + + for key, value := range metadata { + op.SetMetadata(key, value) + } + + return op, c.notifyUpdated() +} + func (c *BugCache) EditComment(target entity.Id, message string) (*bug.EditCommentOperation, error) { author, err := c.repoCache.GetUserIdentity() if err != nil { diff --git a/commands/bridge_auth_addtoken.go b/commands/bridge_auth_addtoken.go index 9a937f4d..338b170e 100644 --- a/commands/bridge_auth_addtoken.go +++ b/commands/bridge_auth_addtoken.go @@ -86,7 +86,7 @@ func runBridgeTokenAdd(cmd *cobra.Command, args []string) error { } } - token := auth.NewToken(value, bridgeAuthAddTokenTarget) + token := auth.NewToken(bridgeAuthAddTokenTarget, value) token.SetMetadata(auth.MetaKeyLogin, bridgeAuthAddTokenLogin) if err := token.Validate(); err != nil { diff --git a/commands/bridge_configure.go b/commands/bridge_configure.go index 0e29d06a..89553633 100644 --- a/commands/bridge_configure.go +++ b/commands/bridge_configure.go @@ -153,12 +153,13 @@ func promptName(repo repository.RepoConfig) (string, error) { var bridgeConfigureCmd = &cobra.Command{ Use: "configure", Short: "Configure a new bridge.", - Long: ` Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge. - Repository configuration can be made by passing either the --url flag or the --project and --owner flags. If the three flags are provided git-bug will use --project and --owner flags. - Token configuration can be directly passed with the --token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one.`, + Long: ` Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge.`, Example: `# Interactive example [1]: github -[2]: launchpad-preview +[2]: gitlab +[3]: jira +[4]: launchpad-preview + target: 1 name [default]: default @@ -215,12 +216,13 @@ func init() { bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureName, "name", "n", "", "A distinctive name to identify the bridge") bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureTarget, "target", "t", "", fmt.Sprintf("The target of the bridge. Valid values are [%s]", strings.Join(bridge.Targets(), ","))) - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.URL, "url", "u", "", "The URL of the target repository") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.BaseURL, "base-url", "b", "", "The base URL of your issue tracker service") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Owner, "owner", "o", "", "The owner of the target repository") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for the API (see \"git-bug bridge auth\")") - bridgeConfigureCmd.Flags().StringVar(&bridgeConfigureToken, "token", "", "A raw authentication token for the API") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.URL, "url", "u", "", "The URL of the remote repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.BaseURL, "base-url", "b", "", "The base URL of your remote issue tracker") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Login, "login", "l", "", "The login on your remote issue tracker") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.CredPrefix, "credential", "c", "", "The identifier or prefix of an already known credential for your remote issue tracker (see \"git-bug bridge auth\")") + bridgeConfigureCmd.Flags().StringVar(&bridgeConfigureToken, "token", "", "A raw authentication token for the remote issue tracker") bridgeConfigureCmd.Flags().BoolVar(&bridgeConfigureTokenStdin, "token-stdin", false, "Will read the token from stdin and ignore --token") - bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Project, "project", "p", "", "The name of the target repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Owner, "owner", "o", "", "The owner of the remote repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeConfigureParams.Project, "project", "p", "", "The name of the remote repository") bridgeConfigureCmd.Flags().SortFlags = false } diff --git a/doc/jira_bridge.md b/doc/jira_bridge.md new file mode 100644 index 00000000..df56bb2d --- /dev/null +++ b/doc/jira_bridge.md @@ -0,0 +1,377 @@ +# JIRA Bridge + +## Design Notes + +### One bridge = one project + +There aren't any huge technical barriers requiring this, but since git-bug lacks +a notion of "project" there is no way to know which project to export new bugs +to as issues. Also, JIRA projects are first-class immutable metadata and so we +*must* get it right on export. Therefore the bridge is configured with the `Key` +for the project it is assigned to. It will only import bugs from that project. + +### JIRA fields + +The bridge currently does nothing to import any of the JIRA fields that don't +have `git-bug` equivalents ("Assignee", "sprint", "story points", etc). +Hopefully the bridge will be able to enable synchronization of these soon. + +### Credentials + +JIRA does not support user/personal access tokens. They have experimental +3-legged oauth support but that requires an API token for the app configured +by the server administrator. The only reliable authentication mechanism then is +the username/password and session-token mechanims. We can aquire a session +token programatically from the username/password but these are very short lived +(i.e. hours or less). As such the bridge currently requires an actual username +and password as user credentials. It supports three options: + +1. Storing both username and password in a separate file referred to by + the `git-config` (I like to use `.git/jira-credentials.json`) +2. Storing the username and password in clear-text in the git config +3. Storing the username only in the git config and asking for the password + on each `push` or `pull`. + +### Issue Creation Defaults + +When a new issues is created in JIRA there are often certain mandatory fields +that require a value or the creation is rejected. In the issue create form on +the JIRA web interface, these are annotated as "required". The `issuetype` is +always required (e.g. "bug", "story", "task", etc). The set of required metadata +is configurable (in JIRA) per `issuetype` so the set might be different between +"bug" and "story", for example. + +For now, the bridge only supports exporting issues as a single `issuetype`. If +no configuration is provied, then the default is `"id": "10001"` which is +`"story"` in the default set of issue types. + +In addition to specifying the `issuetype` of issues created on export, the +bridge will also allow you to specify a constant global set of default values +for any additional required fields. See the configuration section below for the +syntax. + +For longer term goals, see the section below on workflow validation + +### Assign git-bug id to field during issue creation + +JIRA allows for the inclusion of custom "fields" in all of their issues. The +JIRA bridge will store the JIRA issue "id" for any bugs which are synchronized +to JIRA, but it can also assign to a custom JIRA `field` the `git-bug` id. This +way the `git-bug` id can be displayed in the JIRA web interface and certain +integration activities become easier. + +See the configuration section below on how to specify the custom field where the +JIRA bridge should write this information. + + +### Workflows and Transitions + +JIRA issue states are subject to customizable "workflows" (project managers +apparently validate themselves by introducing developer friction). In general, +issues can only transition from one state to another if there is an edge between +them in the state graph (a.k.a. "workflow"). JIRA calls these edges +"transitions". Furthermore, each transition may include a set of mandatory +fields which must be set in order for the transition to succeed. For example the +transition of `"status"` from `"In Progress"` to `"Closed"` might required a +`"resolution"` (i.e. `"Fixed"` or `"Working as intended"`). + +Dealing with complex workflows is going to be challenging. Some long-term +aspirations are described in the section below on "Workflow Validation". +Currently the JIRA bridge isn't very smart about transitions though, so you'll +need to tell it what you want it to do when importing and exporting a state +change (i.e. to "close" or "open" a bug). Currently the bridge accepts +configuration options which map the two `git-bug` statuses ("open", "closed") to +two JIRA statuses. On import, the JIRA status is mapped to a `git-bug` status +(if a mapping exists) and the `git-bug` status is assigned. On export, the +`git-bug` status is mapped to a JIRA status and if a mapping exists the bridge +will query the list of available transitions for the issue. If a transition +exists to the desired state the bridge will attempt to execute the transition. +It does not currently support assigning any fields during the transition so if +any fields are required the transition will fail during export and the status +will be out of sync. + +### JIRA Changelog + +Some operations on JIRA issues are visible in a timeline view known as the +`changelog`. The JIRA cloud product provides an +`/issue/{issueIdOrKey}/changelog` endpoint which provides a paginated view but +the JIRA server product does not. The changelog is visible by querying the issue +with the `expand=changelog` query parameter. Unfortunately in this case the +entire changelog is provided without paging. + +Each changelog entry is identified with a unique string `id`, but within a +single changelog entry is a list of multilple fields that are modified. In other +words a single "event" might atomically change multiple fields. As an example, +when an issue is closed the `"status"` might change to `"closed"` and the +`"resolution"` might change to `"fixed'`. + +When a changelog entry is imported by the JIRA bridge, each individual field +that was changed is treated as a separate `git-bug` operation. In other words a +single JIRA change event might create more than one `git-bug` operation. + +However, when a `git-bug` operation is exported to JIRA it will only create a +single changelog entry. Furthermore, when we modify JIRA issues over the REST +API JIRA does not provide any information to associate that modification event +with the changelog. We must, therefore, herustically match changelog entries +against operations that we performed in order to not import them as duplicate +events. In order to assist in this matching proceess, the bridge will record the +JIRA server time of the response to the `POST` (as reported by the `"Date"` +response header). During import, we keep an iterator to the list of `git-bug` +operations for the bug mapped to the Jira issue. As we walk the JIRA changelog, +we keep the iterator pointing to the first operation with an annotation which is +*not before* that changelog entry. If the changelog entry is the result of an +exported `git-bug` operation, then this must be that operation. We then scan +through the list of changeitems (changed fields) in the changelog entry, and if +we can match a changed field to the candidate `git-bug` operation then we have +identified the match. + +### Unlogged Changes + +Comments (creation and edition) do not show up in the JIRA changelog. However +JIRA reports both a `created` and `updated` date for each comment. If we +import a comment which has an `updated` and `created` field which do not match, +then we treat that as a new comment edition. If we do not already have the +comment imported, then we import an empty comment followed by a comment edition. + +Because comment editions are not uniquely identified in JIRA we identify them +in `git-bug` by concatinating the JIRA issue `id` with the `updated` time of +the edition. + +### Workflow Validation (future) + +The long-term plan for the JIRA bridge is to download and store the workflow +specifiations from the JIRA server. This includes the required metadata for +issue creation, and the status state graph, and the set of required metadata for +status transition. + +When an existing `git-bug` is initially marked for export, the bridge will hook +in and validate the bug state against the required metadata. Then it will prompt +for any missing metadata using a set of UI components appropriate for the field +schema as reported by JIRA. If the user cancels then the bug will not be marked +for export. + +When a bug already marked for JIRA export (including those that were imported) +is modified, the bridge will hook in and validate the modification against the +workflow specifications. It will prompt for any missing metadata as in the +creation process. + +During export, the bridge will validate any export operations and skip them if +we know they will fail due to violation of the cached workflow specification +(i.e. missing required fields for a transition). A list of bugs "blocked for +export" will be available to query. A UI command will allow the user to inspect +and resolve any bugs that are "blocked for export". + +## Configuration + +As mentioned in the notes above, there are a few optional configuration fields +that can be set beyond those that are prompted for during the initial bridge +configuration. You can set these options in your `.git/config` file: + +### Issue Creation Defaults + +The format for this config entry is a JSON object containing fields you wish to +set during issue creation when exproting bugs. If you provide a value for this +configuration option, it must include at least the `"issuetype"` field, or +the bridge will not be able to export any new issues. + +Let's say that we want bugs exported to JIRA to have a default issue type of +"Story" which is `issuetype` with id `10001`. Then we will add the following +entry to our git-config: + +``` +create-issue-defaults = {"issuetype":"10001"} +``` + +If you needed an additional required field `customfield_1234` and you wanted to +provide a default value of `"default"` then you would add the following to your +config: + +``` +create-issue-defaults = {"issuetype":"10001","customfield_1234":"default"} +``` + +Note that the content of this value is merged verbatim to the JSON object that +is `POST`ed to the JIRA rest API, so you can use arbitrary valid JSON. + + +### Assign git-bug id to field + +If you want the bridge to fill a JIRA field with the `git-bug` id when exporting +issues, then provide the name of the field: + +``` +create-issue-gitbug-id = "customfield_5678" +``` + +### Status Map + +You can specify the mapping between `git-bug` status and JIRA status id's using +the following: +``` +bug-id-map = {\"open\": \"1\", \"closed\": \"6\"} +``` + +The format of the map is `<git-bug-status-name>: <jira-status-id>`. In general +your jira instance will have more statuses than `git-bug` will and you may map +more than one jira-status to a git-bug status. You can do this with +`bug-id-revmap`: +``` +bug-id-revmap = {\"10109\": \"open\", \"10006\": \"open\", \"10814\": \"open\"} +``` + +The reverse map `bug-id-revmap` will automatically include the inverse of the +forward map `bug-id-map`. + +Note that in JIRA each different `issuetype` can have a different set of +statuses. The bridge doesn't currently support more than one mapping, however. +Also, note that the format of the map is JSON and the git config file syntax +requires doublequotes to be escaped (as in the examples above). + +### Full example + +Here is an example configuration with all optional fields set +``` +[git-bug "bridge.default"] + project = PROJ + credentials-file = .git/jira-credentials.json + target = jira + server = https://jira.example.com + create-issue-defaults = {"issuetype":"10001","customfield_1234":"default"} + create-issue-gitbug-id = "customfield_5678" + bug-open-id = 1 + bug-closed-id = 6 +``` + +## To-Do list + +* [0cf5c71] Assign git-bug to jira field on import +* [8acce9c] Download and cache workflow representation +* [95e3d45] Implement workflow gui +* [c70e22a] Implement additional query filters for import +* [9ecefaa] Create JIRA mock and add REST unit tests +* [67bf520] Create import/export integration tests +* [1121826] Add unit tests for utilites +* [0597088] Use OS keyring for credentials +* [d3e8f79] Don't count on the `Total` value in paginations + + +## Using CURL to poke at your JIRA's REST API + +If you need to lookup the `id` for any `status`es or the `schema` for any +creation metadata, you can use CURL to query the API from the command line. +Here are a couple of examples to get you started. + +### Getting a session token + +``` +curl \ + --data '{"username":"<username>", "password":"<password>"}' \ + --header "Content-Type: application/json" \ + --request POST \ + <serverUrl>/rest/auth/1/session +``` + +**Note**: If you have a json pretty printer installed (`sudo apt install jq`), +pipe the output through through that to make things more readable: + +``` +curl --silent \ + --data '{"username":"<username>", "password":"<password>"}' \ + --header "Content-Type: application/json" \ + --request POST + <serverUrl>/rest/auth/1/session | jq . +``` + +example output: +``` +{ + "session": { + "name": "JSESSIONID", + "value": "{sessionToken}" + }, + "loginInfo": { + "loginCount": 268, + "previousLoginTime": "2019-11-12T08:03:35.300-0800" + } +} +``` + +Make note of the output value. On subsequent invocations of `curl`, append the +following command-line option: + +``` +--cookie "JSESSIONID={sessionToken}" +``` + +Where `{sessionToken}` is the output from the `POST` above. + +### Get a list of issuetype ids + +``` +curl --silent \ + --cookie "JSESSIONID={sessionToken}" \ + --header "Content-Type: application/json" \ + --request GET https://jira.example.com/rest/api/2/issuetype \ + | jq . +``` + +**example output**: +``` + { + "self": "https://jira.example.com/rest/api/2/issuetype/13105", + "id": "13105", + "description": "", + "iconUrl": "https://jira.example.com/secure/viewavatar?size=xsmall&avatarId=10316&avatarType=issuetype", + "name": "Test Plan Links", + "subtask": true, + "avatarId": 10316 + }, + { + "self": "https://jira.example.com/rest/api/2/issuetype/13106", + "id": "13106", + "description": "", + "iconUrl": "https://jira.example.com/secure/viewavatar?size=xsmall&avatarId=10316&avatarType=issuetype", + "name": "Enable Initiatives on the project", + "subtask": true, + "avatarId": 10316 + }, + ... +``` + + +### Get a list of statuses + + +``` +curl --silent \ + --cookie "JSESSIONID={sessionToken}" \ + --header "Content-Type: application/json" \ + --request GET https://jira.example.com/rest/api/2/project/{projectIdOrKey}/statuses \ + | jq . +``` + +**example output:** +``` +[ + { + "self": "https://example.com/rest/api/2/issuetype/3", + "id": "3", + "name": "Task", + "subtask": false, + "statuses": [ + { + "self": "https://example.com/rest/api/2/status/1", + "description": "The issue is open and ready for the assignee to start work on it.", + "iconUrl": "https://example.com/images/icons/statuses/open.png", + "name": "Open", + "id": "1", + "statusCategory": { + "self": "https://example.com/rest/api/2/statuscategory/2", + "id": 2, + "key": "new", + "colorName": "blue-gray", + "name": "To Do" + } + }, +... +``` diff --git a/doc/man/git-bug-add.1 b/doc/man/git-bug-add.1 index 2d47f0ac..9b0e6ec7 100644 --- a/doc/man/git-bug-add.1 +++ b/doc/man/git-bug-add.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,19 +20,19 @@ Create a new bug. .SH OPTIONS .PP \fB\-t\fP, \fB\-\-title\fP="" - Provide a title to describe the issue + Provide a title to describe the issue .PP \fB\-m\fP, \fB\-\-message\fP="" - Provide a message to describe the issue + Provide a message to describe the issue .PP \fB\-F\fP, \fB\-\-file\fP="" - Take the message from the given file. Use \- to read the message from the standard input + Take the message from the given file. Use \- to read the message from the standard input .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for add + help for add .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-auth-add-token.1 b/doc/man/git-bug-bridge-auth-add-token.1 index c9ca55d6..fe4750b4 100644 --- a/doc/man/git-bug-bridge-auth-add-token.1 +++ b/doc/man/git-bug-bridge-auth-add-token.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-bridge\-auth\-add\-token \- Store a new token .SH SYNOPSIS .PP -\fBgit\-bug bridge auth add\-token [<token>] [flags]\fP +\fBgit\-bug bridge auth add\-token [] [flags]\fP .SH DESCRIPTION @@ -21,19 +20,19 @@ Store a new token .SH OPTIONS .PP \fB\-t\fP, \fB\-\-target\fP="" - The target of the bridge. Valid values are [github,gitlab,launchpad\-preview] + The target of the bridge. Valid values are [github,gitlab,jira,launchpad\-preview] .PP \fB\-l\fP, \fB\-\-login\fP="" - The login in the remote bug\-tracker + 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 + The user to add the token to. Default is the current user .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for add\-token + help for add\-token .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-auth-rm.1 b/doc/man/git-bug-bridge-auth-rm.1 index b0222b72..9ddac3f2 100644 --- a/doc/man/git-bug-bridge-auth-rm.1 +++ b/doc/man/git-bug-bridge-auth-rm.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-bridge\-auth\-rm \- Remove a credential. .SH SYNOPSIS .PP -\fBgit\-bug bridge auth rm <id> [flags]\fP +\fBgit\-bug bridge auth rm [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Remove a credential. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for rm + help for rm .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-auth-show.1 b/doc/man/git-bug-bridge-auth-show.1 index 6e0d345c..ae5d2039 100644 --- a/doc/man/git-bug-bridge-auth-show.1 +++ b/doc/man/git-bug-bridge-auth-show.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ Display an authentication credential. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for show + help for show .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-auth.1 b/doc/man/git-bug-bridge-auth.1 index 0e400c41..02f2ca0f 100644 --- a/doc/man/git-bug-bridge-auth.1 +++ b/doc/man/git-bug-bridge-auth.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ List all known bridge authentication credentials. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for auth + help for auth .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-configure.1 b/doc/man/git-bug-bridge-configure.1 index d1dc9f7d..cc66487f 100644 --- a/doc/man/git-bug-bridge-configure.1 +++ b/doc/man/git-bug-bridge-configure.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -19,8 +18,6 @@ git\-bug\-bridge\-configure \- Configure a new bridge. .nf Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge. -Repository configuration can be made by passing either the \-\-url flag or the \-\-project and \-\-owner flags. If the three flags are provided git\-bug will use \-\-project and \-\-owner flags. -Token configuration can be directly passed with the \-\-token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one. .fi .RE @@ -29,43 +26,47 @@ Token configuration can be directly passed with the \-\-token flag or in the ter .SH OPTIONS .PP \fB\-n\fP, \fB\-\-name\fP="" - A distinctive name to identify the bridge + A distinctive name to identify the bridge .PP \fB\-t\fP, \fB\-\-target\fP="" - The target of the bridge. Valid values are [github,gitlab,launchpad\-preview] + The target of the bridge. Valid values are [github,gitlab,jira,launchpad\-preview] .PP \fB\-u\fP, \fB\-\-url\fP="" - The URL of the target repository + The URL of the remote repository .PP \fB\-b\fP, \fB\-\-base\-url\fP="" - The base URL of your issue tracker service + The base URL of your remote issue tracker .PP -\fB\-o\fP, \fB\-\-owner\fP="" - The owner of the target repository +\fB\-l\fP, \fB\-\-login\fP="" + The login on your remote issue tracker .PP \fB\-c\fP, \fB\-\-credential\fP="" - The identifier or prefix of an already known credential for the API (see "git\-bug bridge auth") + The identifier or prefix of an already known credential for your remote issue tracker (see "git\-bug bridge auth") .PP \fB\-\-token\fP="" - A raw authentication token for the API + A raw authentication token for the remote issue tracker .PP \fB\-\-token\-stdin\fP[=false] - Will read the token from stdin and ignore \-\-token + Will read the token from stdin and ignore \-\-token + +.PP +\fB\-o\fP, \fB\-\-owner\fP="" + The owner of the remote repository .PP \fB\-p\fP, \fB\-\-project\fP="" - The name of the target repository + The name of the remote repository .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for configure + help for configure .SH EXAMPLE @@ -75,7 +76,10 @@ Token configuration can be directly passed with the \-\-token flag or in the ter .nf # Interactive example [1]: github -[2]: launchpad\-preview +[2]: gitlab +[3]: jira +[4]: launchpad\-preview + target: 1 name [default]: default diff --git a/doc/man/git-bug-bridge-pull.1 b/doc/man/git-bug-bridge-pull.1 index c6614627..055d2472 100644 --- a/doc/man/git-bug-bridge-pull.1 +++ b/doc/man/git-bug-bridge-pull.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-bridge\-pull \- Pull updates. .SH SYNOPSIS .PP -\fBgit\-bug bridge pull [<name>] [flags]\fP +\fBgit\-bug bridge pull [] [flags]\fP .SH DESCRIPTION @@ -21,15 +20,15 @@ Pull updates. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for pull + help for pull .PP \fB\-n\fP, \fB\-\-no\-resume\fP[=false] - force importing all bugs + force importing all bugs .PP \fB\-s\fP, \fB\-\-since\fP="" - import only bugs updated after the given date (ex: "200h" or "june 2 2019") + import only bugs updated after the given date (ex: "200h" or "june 2 2019") .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-push.1 b/doc/man/git-bug-bridge-push.1 index 1257781b..59e60bdf 100644 --- a/doc/man/git-bug-bridge-push.1 +++ b/doc/man/git-bug-bridge-push.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-bridge\-push \- Push updates. .SH SYNOPSIS .PP -\fBgit\-bug bridge push [<name>] [flags]\fP +\fBgit\-bug bridge push [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Push updates. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for push + help for push .SH SEE ALSO diff --git a/doc/man/git-bug-bridge-rm.1 b/doc/man/git-bug-bridge-rm.1 index 324d4237..8cfa925a 100644 --- a/doc/man/git-bug-bridge-rm.1 +++ b/doc/man/git-bug-bridge-rm.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-bridge\-rm \- Delete a configured bridge. .SH SYNOPSIS .PP -\fBgit\-bug bridge rm <name> [flags]\fP +\fBgit\-bug bridge rm [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Delete a configured bridge. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for rm + help for rm .SH SEE ALSO diff --git a/doc/man/git-bug-bridge.1 b/doc/man/git-bug-bridge.1 index 8e885f10..8d5c7dc0 100644 --- a/doc/man/git-bug-bridge.1 +++ b/doc/man/git-bug-bridge.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ Configure and use bridges to other bug trackers. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for bridge + help for bridge .SH SEE ALSO diff --git a/doc/man/git-bug-commands.1 b/doc/man/git-bug-commands.1 index dec359f5..b7c1e214 100644 --- a/doc/man/git-bug-commands.1 +++ b/doc/man/git-bug-commands.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-commands \- Display available commands. .SH SYNOPSIS .PP -\fBgit\-bug commands [<option>\&...] [flags]\fP +\fBgit\-bug commands [\&...] [flags]\fP .SH DESCRIPTION @@ -21,11 +20,11 @@ Display available commands. .SH OPTIONS .PP \fB\-p\fP, \fB\-\-pretty\fP[=false] - Output the command description as well as Markdown compatible comment + Output the command description as well as Markdown compatible comment .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for commands + help for commands .SH SEE ALSO diff --git a/doc/man/git-bug-comment-add.1 b/doc/man/git-bug-comment-add.1 index c25177a2..530101b8 100644 --- a/doc/man/git-bug-comment-add.1 +++ b/doc/man/git-bug-comment-add.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-comment\-add \- Add a new comment to a bug. .SH SYNOPSIS .PP -\fBgit\-bug comment add [<id>] [flags]\fP +\fBgit\-bug comment add [] [flags]\fP .SH DESCRIPTION @@ -21,15 +20,15 @@ Add a new comment to a bug. .SH OPTIONS .PP \fB\-F\fP, \fB\-\-file\fP="" - Take the message from the given file. Use \- to read the message from the standard input + Take the message from the given file. Use \- to read the message from the standard input .PP \fB\-m\fP, \fB\-\-message\fP="" - Provide the new message from the command line + Provide the new message from the command line .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for add + help for add .SH SEE ALSO diff --git a/doc/man/git-bug-comment.1 b/doc/man/git-bug-comment.1 index d70d7078..9beb7a06 100644 --- a/doc/man/git-bug-comment.1 +++ b/doc/man/git-bug-comment.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-comment \- Display or add comments to a bug. .SH SYNOPSIS .PP -\fBgit\-bug comment [<id>] [flags]\fP +\fBgit\-bug comment [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Display or add comments to a bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for comment + help for comment .SH SEE ALSO diff --git a/doc/man/git-bug-deselect.1 b/doc/man/git-bug-deselect.1 index 61536493..7cdf5112 100644 --- a/doc/man/git-bug-deselect.1 +++ b/doc/man/git-bug-deselect.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ Clear the implicitly selected bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for deselect + help for deselect .SH EXAMPLE diff --git a/doc/man/git-bug-label-add.1 b/doc/man/git-bug-label-add.1 index 893edbcc..86a4bb24 100644 --- a/doc/man/git-bug-label-add.1 +++ b/doc/man/git-bug-label-add.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-label\-add \- Add a label to a bug. .SH SYNOPSIS .PP -\fBgit\-bug label add [<id>] <label>[...] [flags]\fP +\fBgit\-bug label add [] [...] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Add a label to a bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for add + help for add .SH SEE ALSO diff --git a/doc/man/git-bug-label-rm.1 b/doc/man/git-bug-label-rm.1 index 3cc76b82..e7e912ea 100644 --- a/doc/man/git-bug-label-rm.1 +++ b/doc/man/git-bug-label-rm.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-label\-rm \- Remove a label from a bug. .SH SYNOPSIS .PP -\fBgit\-bug label rm [<id>] <label>[...] [flags]\fP +\fBgit\-bug label rm [] [...] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Remove a label from a bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for rm + help for rm .SH SEE ALSO diff --git a/doc/man/git-bug-label.1 b/doc/man/git-bug-label.1 index 14014227..23fc0047 100644 --- a/doc/man/git-bug-label.1 +++ b/doc/man/git-bug-label.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-label \- Display, add or remove labels to/from a bug. .SH SYNOPSIS .PP -\fBgit\-bug label [<id>] [flags]\fP +\fBgit\-bug label [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Display, add or remove labels to/from a bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for label + help for label .SH SEE ALSO diff --git a/doc/man/git-bug-ls-id.1 b/doc/man/git-bug-ls-id.1 index 5962f1b9..c7b40114 100644 --- a/doc/man/git-bug-ls-id.1 +++ b/doc/man/git-bug-ls-id.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-ls\-id \- List bug identifiers. .SH SYNOPSIS .PP -\fBgit\-bug ls\-id [<prefix>] [flags]\fP +\fBgit\-bug ls\-id [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ List bug identifiers. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for ls\-id + help for ls\-id .SH SEE ALSO diff --git a/doc/man/git-bug-ls-label.1 b/doc/man/git-bug-ls-label.1 index c5b7a807..30e63f94 100644 --- a/doc/man/git-bug-ls-label.1 +++ b/doc/man/git-bug-ls-label.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -24,7 +23,7 @@ Note: in the future, a proper label policy could be implemented where valid labe .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for ls\-label + help for ls\-label .SH SEE ALSO diff --git a/doc/man/git-bug-ls.1 b/doc/man/git-bug-ls.1 index aae57c1d..2fc1d337 100644 --- a/doc/man/git-bug-ls.1 +++ b/doc/man/git-bug-ls.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-ls \- List bugs. .SH SYNOPSIS .PP -\fBgit\-bug ls [<query>] [flags]\fP +\fBgit\-bug ls [] [flags]\fP .SH DESCRIPTION @@ -24,43 +23,43 @@ You can pass an additional query to filter and order the list. This query can be .SH OPTIONS .PP \fB\-s\fP, \fB\-\-status\fP=[] - Filter by status. Valid values are [open,closed] + Filter by status. Valid values are [open,closed] .PP \fB\-a\fP, \fB\-\-author\fP=[] - Filter by author + Filter by author .PP \fB\-p\fP, \fB\-\-participant\fP=[] - Filter by participant + Filter by participant .PP \fB\-A\fP, \fB\-\-actor\fP=[] - Filter by actor + Filter by actor .PP \fB\-l\fP, \fB\-\-label\fP=[] - Filter by label + Filter by label .PP \fB\-t\fP, \fB\-\-title\fP=[] - Filter by title + Filter by title .PP \fB\-n\fP, \fB\-\-no\fP=[] - Filter by absence of something. Valid values are [label] + Filter by absence of something. Valid values are [label] .PP \fB\-b\fP, \fB\-\-by\fP="creation" - Sort the results by a characteristic. Valid values are [id,creation,edit] + Sort the results by a characteristic. Valid values are [id,creation,edit] .PP \fB\-d\fP, \fB\-\-direction\fP="asc" - Select the sorting direction. Valid values are [asc,desc] + Select the sorting direction. Valid values are [asc,desc] .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for ls + help for ls .SH EXAMPLE diff --git a/doc/man/git-bug-pull.1 b/doc/man/git-bug-pull.1 index 5690c80a..82b741ed 100644 --- a/doc/man/git-bug-pull.1 +++ b/doc/man/git-bug-pull.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-pull \- Pull bugs update from a git remote. .SH SYNOPSIS .PP -\fBgit\-bug pull [<remote>] [flags]\fP +\fBgit\-bug pull [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Pull bugs update from a git remote. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for pull + help for pull .SH SEE ALSO diff --git a/doc/man/git-bug-push.1 b/doc/man/git-bug-push.1 index defa06c9..dc694258 100644 --- a/doc/man/git-bug-push.1 +++ b/doc/man/git-bug-push.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-push \- Push bugs update to a git remote. .SH SYNOPSIS .PP -\fBgit\-bug push [<remote>] [flags]\fP +\fBgit\-bug push [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Push bugs update to a git remote. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for push + help for push .SH SEE ALSO diff --git a/doc/man/git-bug-select.1 b/doc/man/git-bug-select.1 index 36728037..4df61221 100644 --- a/doc/man/git-bug-select.1 +++ b/doc/man/git-bug-select.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-select \- Select a bug for implicit use in future commands. .SH SYNOPSIS .PP -\fBgit\-bug select <id> [flags]\fP +\fBgit\-bug select [flags]\fP .SH DESCRIPTION @@ -18,7 +17,7 @@ git\-bug\-select \- Select a bug for implicit use in future commands. Select a bug for implicit use in future commands. .PP -This command allows you to omit any bug <id> argument, for example: +This command allows you to omit any bug argument, for example: git bug show instead of git bug show 2f153ca @@ -30,7 +29,7 @@ The complementary command is "git bug deselect" performing the opposite operatio .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for select + help for select .SH EXAMPLE diff --git a/doc/man/git-bug-show.1 b/doc/man/git-bug-show.1 index 9ad9c019..dae1877b 100644 --- a/doc/man/git-bug-show.1 +++ b/doc/man/git-bug-show.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-show \- Display the details of a bug. .SH SYNOPSIS .PP -\fBgit\-bug show [<id>] [flags]\fP +\fBgit\-bug show [] [flags]\fP .SH DESCRIPTION @@ -21,11 +20,11 @@ Display the details of a bug. .SH OPTIONS .PP \fB\-f\fP, \fB\-\-field\fP="" - Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants] + Select field to display. Valid values are [author,authorEmail,createTime,humanId,id,labels,shortId,status,title,actors,participants] .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for show + help for show .SH SEE ALSO diff --git a/doc/man/git-bug-status-close.1 b/doc/man/git-bug-status-close.1 index 71824337..539c5447 100644 --- a/doc/man/git-bug-status-close.1 +++ b/doc/man/git-bug-status-close.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-status\-close \- Mark a bug as closed. .SH SYNOPSIS .PP -\fBgit\-bug status close [<id>] [flags]\fP +\fBgit\-bug status close [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Mark a bug as closed. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for close + help for close .SH SEE ALSO diff --git a/doc/man/git-bug-status-open.1 b/doc/man/git-bug-status-open.1 index 06c41368..d7aca0d0 100644 --- a/doc/man/git-bug-status-open.1 +++ b/doc/man/git-bug-status-open.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-status\-open \- Mark a bug as open. .SH SYNOPSIS .PP -\fBgit\-bug status open [<id>] [flags]\fP +\fBgit\-bug status open [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Mark a bug as open. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for open + help for open .SH SEE ALSO diff --git a/doc/man/git-bug-status.1 b/doc/man/git-bug-status.1 index 36901a51..df013911 100644 --- a/doc/man/git-bug-status.1 +++ b/doc/man/git-bug-status.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-status \- Display or change a bug status. .SH SYNOPSIS .PP -\fBgit\-bug status [<id>] [flags]\fP +\fBgit\-bug status [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Display or change a bug status. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for status + help for status .SH SEE ALSO diff --git a/doc/man/git-bug-termui.1 b/doc/man/git-bug-termui.1 index 01de82fa..f8ccc42a 100644 --- a/doc/man/git-bug-termui.1 +++ b/doc/man/git-bug-termui.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ Launch the terminal UI. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for termui + help for termui .SH SEE ALSO diff --git a/doc/man/git-bug-title-edit.1 b/doc/man/git-bug-title-edit.1 index 4382a20e..c1903854 100644 --- a/doc/man/git-bug-title-edit.1 +++ b/doc/man/git-bug-title-edit.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-title\-edit \- Edit a title of a bug. .SH SYNOPSIS .PP -\fBgit\-bug title edit [<id>] [flags]\fP +\fBgit\-bug title edit [] [flags]\fP .SH DESCRIPTION @@ -21,11 +20,11 @@ Edit a title of a bug. .SH OPTIONS .PP \fB\-t\fP, \fB\-\-title\fP="" - Provide a title to describe the issue + Provide a title to describe the issue .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for edit + help for edit .SH SEE ALSO diff --git a/doc/man/git-bug-title.1 b/doc/man/git-bug-title.1 index f58cc3c1..e3345acd 100644 --- a/doc/man/git-bug-title.1 +++ b/doc/man/git-bug-title.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-title \- Display or change a title of a bug. .SH SYNOPSIS .PP -\fBgit\-bug title [<id>] [flags]\fP +\fBgit\-bug title [] [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Display or change a title of a bug. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for title + help for title .SH SEE ALSO diff --git a/doc/man/git-bug-user-adopt.1 b/doc/man/git-bug-user-adopt.1 index b3bc07c0..2be2afcb 100644 --- a/doc/man/git-bug-user-adopt.1 +++ b/doc/man/git-bug-user-adopt.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-user\-adopt \- Adopt an existing identity as your own. .SH SYNOPSIS .PP -\fBgit\-bug user adopt <user-id> [flags]\fP +\fBgit\-bug user adopt [flags]\fP .SH DESCRIPTION @@ -21,7 +20,7 @@ Adopt an existing identity as your own. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for adopt + help for adopt .SH SEE ALSO diff --git a/doc/man/git-bug-user-create.1 b/doc/man/git-bug-user-create.1 index f31b7369..099a44bd 100644 --- a/doc/man/git-bug-user-create.1 +++ b/doc/man/git-bug-user-create.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ Create a new identity. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for create + help for create .SH SEE ALSO diff --git a/doc/man/git-bug-user-ls.1 b/doc/man/git-bug-user-ls.1 index 30de5fb6..1ccddb8a 100644 --- a/doc/man/git-bug-user-ls.1 +++ b/doc/man/git-bug-user-ls.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,7 +20,7 @@ List identities. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for ls + help for ls .SH SEE ALSO diff --git a/doc/man/git-bug-user.1 b/doc/man/git-bug-user.1 index dc04cee8..5a4cf439 100644 --- a/doc/man/git-bug-user.1 +++ b/doc/man/git-bug-user.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -10,7 +9,7 @@ git\-bug\-user \- Display or change the user identity. .SH SYNOPSIS .PP -\fBgit\-bug user [<user-id>] [flags]\fP +\fBgit\-bug user [] [flags]\fP .SH DESCRIPTION @@ -21,11 +20,11 @@ Display or change the user identity. .SH OPTIONS .PP \fB\-f\fP, \fB\-\-field\fP="" - Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamport,login,metadata,name] + Select field to display. Valid values are [email,humanId,id,lastModification,lastModificationLamport,login,metadata,name] .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for user + help for user .SH SEE ALSO diff --git a/doc/man/git-bug-version.1 b/doc/man/git-bug-version.1 index 8660417f..963e260e 100644 --- a/doc/man/git-bug-version.1 +++ b/doc/man/git-bug-version.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -21,19 +20,19 @@ Show git\-bug version information. .SH OPTIONS .PP \fB\-n\fP, \fB\-\-number\fP[=false] - Only show the version number + Only show the version number .PP \fB\-c\fP, \fB\-\-commit\fP[=false] - Only show the commit hash + Only show the commit hash .PP \fB\-a\fP, \fB\-\-all\fP[=false] - Show all version informations + Show all version informations .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for version + help for version .SH SEE ALSO diff --git a/doc/man/git-bug-webui.1 b/doc/man/git-bug-webui.1 index 9bcb65fd..62d2e5dc 100644 --- a/doc/man/git-bug-webui.1 +++ b/doc/man/git-bug-webui.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -25,19 +24,19 @@ Available git config: .SH OPTIONS .PP \fB\-\-open\fP[=false] - Automatically open the web UI in the default browser + Automatically open the web UI in the default browser .PP \fB\-\-no\-open\fP[=false] - Prevent the automatic opening of the web UI in the default browser + Prevent the automatic opening of the web UI in the default browser .PP \fB\-p\fP, \fB\-\-port\fP=0 - Port to listen to (default is random) + Port to listen to (default is random) .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for webui + help for webui .SH SEE ALSO diff --git a/doc/man/git-bug.1 b/doc/man/git-bug.1 index 4008ac0d..6dba279e 100644 --- a/doc/man/git-bug.1 +++ b/doc/man/git-bug.1 @@ -1,7 +1,6 @@ -.TH "GIT-BUG" "1" "Apr 2019" "Generated from git-bug's source code" "" .nh -.ad l - +.TH GIT\-BUG(1)Apr 2019 +Generated from git\-bug's source code .SH NAME .PP @@ -26,7 +25,7 @@ the same git remote your are already using to collaborate with other peoples. .SH OPTIONS .PP \fB\-h\fP, \fB\-\-help\fP[=false] - help for git\-bug + help for git\-bug .SH SEE ALSO diff --git a/doc/md/git-bug_bridge_auth_add-token.md b/doc/md/git-bug_bridge_auth_add-token.md index 496455a0..f0f8ac72 100644 --- a/doc/md/git-bug_bridge_auth_add-token.md +++ b/doc/md/git-bug_bridge_auth_add-token.md @@ -13,7 +13,7 @@ git-bug bridge auth add-token [<token>] [flags] ### Options ``` - -t, --target string The target of the bridge. Valid values are [github,gitlab,launchpad-preview] + -t, --target string The target of the bridge. Valid values are [github,gitlab,jira,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 diff --git a/doc/md/git-bug_bridge_configure.md b/doc/md/git-bug_bridge_configure.md index c0f89cf3..f89de404 100644 --- a/doc/md/git-bug_bridge_configure.md +++ b/doc/md/git-bug_bridge_configure.md @@ -5,8 +5,6 @@ Configure a new bridge. ### Synopsis Configure a new bridge by passing flags or/and using interactive terminal prompts. You can avoid all the terminal prompts by passing all the necessary flags to configure your bridge. - Repository configuration can be made by passing either the --url flag or the --project and --owner flags. If the three flags are provided git-bug will use --project and --owner flags. - Token configuration can be directly passed with the --token flag or in the terminal prompt. If you don't already have one you can use the interactive procedure to generate one. ``` git-bug bridge configure [flags] @@ -17,7 +15,10 @@ git-bug bridge configure [flags] ``` # Interactive example [1]: github -[2]: launchpad-preview +[2]: gitlab +[3]: jira +[4]: launchpad-preview + target: 1 name [default]: default @@ -71,14 +72,15 @@ git bug bridge configure \ ``` -n, --name string A distinctive name to identify the bridge - -t, --target string The target of the bridge. Valid values are [github,gitlab,launchpad-preview] - -u, --url string The URL of the target repository - -b, --base-url string The base URL of your issue tracker service - -o, --owner string The owner of the target repository - -c, --credential string The identifier or prefix of an already known credential for the API (see "git-bug bridge auth") - --token string A raw authentication token for the API + -t, --target string The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview] + -u, --url string The URL of the remote repository + -b, --base-url string The base URL of your remote issue tracker + -l, --login string The login on your remote issue tracker + -c, --credential string The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth") + --token string A raw authentication token for the remote issue tracker --token-stdin Will read the token from stdin and ignore --token - -p, --project string The name of the target repository + -o, --owner string The owner of the remote repository + -p, --project string The name of the remote repository -h, --help help for configure ``` @@ -13,7 +13,7 @@ require ( github.com/dustin/go-humanize v1.0.0 github.com/fatih/color v1.9.0 github.com/go-errors/errors v1.0.1 - github.com/gorilla/mux v1.7.3 + github.com/gorilla/mux v1.7.4 github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 github.com/mattn/go-isatty v0.0.12 @@ -22,11 +22,11 @@ require ( github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e - github.com/spf13/cobra v0.0.5 - github.com/stretchr/testify v1.4.0 + github.com/spf13/cobra v0.0.6 + github.com/stretchr/testify v1.5.1 github.com/theckman/goconstraint v1.11.0 github.com/vektah/gqlparser v1.3.1 - github.com/xanzy/go-gitlab v0.24.0 + github.com/xanzy/go-gitlab v0.26.0 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 golang.org/x/sync v0.0.0-20190423024810-112230192c58 @@ -1,3 +1,4 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/99designs/gqlgen v0.10.3-0.20200208093655-ab8d62b67dd0 h1:ADy3XJwhOYg6Pb90XeXazWvO+9gpOsgLuaM1buZUZOY= github.com/99designs/gqlgen v0.10.3-0.20200208093655-ab8d62b67dd0/go.mod h1:dfBhwZKMcSYiYRMTs8qWF+Oha6782e1xPfgRmVal9I8= github.com/99designs/gqlgen v0.10.3-0.20200209012558-b7a58a1c0e4b h1:510xa84qGbDemwTHNio4cLWkdKFxxJgVtsIOH+Ku8bo= @@ -8,8 +9,11 @@ github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0 github.com/Masterminds/vcs v1.13.0/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA= github.com/MichaelMure/go-term-text v0.2.6 h1:dSmJSzk2iI5xWymSMrMbdVM1bxYWu3DjDFhdcJvAuqA= github.com/MichaelMure/go-term-text v0.2.6/go.mod h1:o2Z5T3b28F4kwAojGvvNdbzjHf9t18vbQ7E2pmTe2Ww= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/agnivade/levenshtein v1.0.1 h1:3oJU7J3FGFmyhn8KHjmVaZCN5hxTr7GxgRue+sxIXdQ= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/araddon/dateparse v0.0.0-20190622164848-0fb0a474d195 h1:c4mLfegoDw6OhSJXTd2jUEQgZUQuJWtocudb97Qn9EM= @@ -19,40 +23,71 @@ github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986 h1:QvIfX96O1 github.com/awesome-gocui/gocui v0.6.1-0.20191115151952-a34ffb055986/go.mod h1:1QikxFaPhe2frKeKvEwZEIGia3haiOxOUXKinrv17mA= github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc h1:wGNpKcHU8Aadr9yOzsT3GEsFLS7HQu8HxQIomnekqf0= github.com/awesome-gocui/termbox-go v0.0.0-20190427202837-c0aef3d18bcc/go.mod h1:tOy3o5Nf1bA17mnK4W41gD7PS3u4Cv0P0pqFcoWMy8s= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9 h1:a1zrFsLFac2xoM6zG1u72DWJwZG3ayttYLfmLbxVETk= github.com/cheekybits/genny v0.0.0-20170328200008-9127e812e1e9/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/codegangsta/cli v1.20.0/go.mod h1:/qJNoX69yVSKu5o4jLyXAENLRyk1uhi7zkbQ3slBdOA= +github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/corpix/uarand v0.1.1 h1:RMr1TWc9F4n5jiPDzFHtmaUXLKLNUFK0SgCLo4BhX/U= github.com/corpix/uarand v0.1.1/go.mod h1:SFKZvkcRoLqVRFZ4u25xPmp6m9ktANfbpXZ7SJ0/FNU= github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= +github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= +github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.2.0 h1:VJtLvh6VQym50czpZzx07z/kw9EgAxI3x1ZB8taTMQQ= github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= @@ -62,6 +97,12 @@ github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428 h1:Mo9W14pwbO9VfRe+y github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428/go.mod h1:uhpZMVGznybq1itEKXj6RYw9I71qK4kH+OGMjRC4KEo= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -80,25 +121,41 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.6 h1:V2iyH+aX9C5fsYCpK60U8BYIvmhqxuOL3JZcqc1NB7k= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/ngdinhtoan/glide-cleanup v0.2.0/go.mod h1:UQzsmiDOb8YV3nOsCxK/c9zPpCZVNoHScRE3EO9pVMM= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/opentracing/basictracer-go v1.0.0/go.mod h1:QfBfYuafItcjQuMwinw9GhYKwFXS9KnPs5lxoYwgW74= github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5 h1:rZQtoozkfsiNs36c7Tdv/gyGNzD1X1XWKO8rptVNZuM= github.com/phayes/freeport v0.0.0-20171002181615-b8543db493a5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7 h1:Vk3RiBQpF0Ja+OqbFG7lYTk79+l8Cm2QESLXB0x6u6U= @@ -106,25 +163,39 @@ github.com/shurcooL/githubv4 v0.0.0-20190601194912-068505affed7/go.mod h1:hAF0iL github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/vfsgen v0.0.0-20180121065927-ffb13db8def0/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e h1:VAzdS5Nw68fbf5RZ8RDVlUvPXNU6Z3jtPCK/qvm4FoQ= github.com/skratchdot/open-golang v0.0.0-20190402232053-79abb63cd66e/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= +github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs= +github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/theckman/goconstraint v1.11.0 h1:oBUwN5wpE4dwyPhRGraEgJsFTr+JtLWiDnaJZJeeXI0= github.com/theckman/goconstraint v1.11.0/go.mod h1:zkCR/f2kOULTk/h1ujgyB9BlCNLaqlQ6GN2Zl4mg81g= +github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/vektah/dataloaden v0.2.1-0.20190515034641-a19b9a6e7c9e/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= @@ -138,25 +209,48 @@ github.com/xanzy/go-gitlab v0.22.1 h1:TVxgHmoa35jQL+9FCkG0nwPDxU9dQZXknBTDtGaSFn github.com/xanzy/go-gitlab v0.22.1/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= github.com/xanzy/go-gitlab v0.24.0 h1:zP1zC4K76Gha0coN5GhygOLhsHTCvUjrnqGL3kHXkVU= github.com/xanzy/go-gitlab v0.24.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= +github.com/xanzy/go-gitlab v0.25.0 h1:G5aTZeqZd66Q6qMVieBfmHBsPpF0jY92zCLAMpULe3I= +github.com/xanzy/go-gitlab v0.25.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= +github.com/xanzy/go-gitlab v0.26.0 h1:eAnJRBUC+GDJSy8OoGCZBqBMpXsGOOT235TFm/F8C0Q= +github.com/xanzy/go-gitlab v0.26.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= +github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181108082009-03003ca0c849/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288 h1:JIqe8uIcRBHXDQVvZtHwp80ai3Lw3IJAeJEs55Dc1W0= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -171,18 +265,31 @@ golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190125232054-d66bd3c5d5a6/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190515012406-7d7faa4812bd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589 h1:rjUrONFu4kLchcZTfp3/96bR8bW8dIa8uz3cR5n0cgM= golang.org/x/tools v0.0.0-20200114235610-7ae403b6b589/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= sourcegraph.com/sourcegraph/appdash v0.0.0-20180110180208-2cc67fd64755/go.mod h1:hI742Nqp5OhwiqlzhgfbWU4mW4yO10fP+LoT9WOswdU= sourcegraph.com/sourcegraph/appdash-data v0.0.0-20151005221446-73f23eafcf67/go.mod h1:L5q+DGLGOQFpo1snNEkLOJT2d1YTW66rWNzatr3He1k= diff --git a/identity/bare.go b/identity/bare.go index a02ec790..58c278be 100644 --- a/identity/bare.go +++ b/identity/bare.go @@ -21,6 +21,8 @@ var _ entity.Interface = &Bare{} // // in particular, this identity is designed to be compatible with the handling of // identities in the early version of git-bug. +// Deprecated: legacy identity for compat, might make sense to ditch entirely for +// simplicity but that would be a breaking change. type Bare struct { id entity.Id name string diff --git a/input/prompt.go b/input/prompt.go index 960ecd62..12aa7b92 100644 --- a/input/prompt.go +++ b/input/prompt.go @@ -3,13 +3,18 @@ package input import ( "bufio" "fmt" + "net/url" "os" + "sort" "strconv" "strings" "syscall" + "time" "golang.org/x/crypto/ssh/terminal" + "github.com/MichaelMure/git-bug/bridge/core/auth" + "github.com/MichaelMure/git-bug/util/colors" "github.com/MichaelMure/git-bug/util/interrupt" ) @@ -26,11 +31,29 @@ func Required(name string, value string) (string, error) { return "", nil } +// IsURL is a validator checking that the value is a fully formed URL +func IsURL(name string, value string) (string, error) { + u, err := url.Parse(value) + if err != nil { + return fmt.Sprintf("%s is invalid: %v", name, err), nil + } + if u.Scheme == "" { + return fmt.Sprintf("%s is missing a scheme", name), nil + } + if u.Host == "" { + return fmt.Sprintf("%s is missing a host", name), nil + } + return "", nil +} + +// Prompts + func Prompt(prompt, name string, validators ...PromptValidator) (string, error) { return PromptDefault(prompt, name, "", validators...) } func PromptDefault(prompt, name, preValue string, validators ...PromptValidator) (string, error) { +loop: for { if preValue != "" { _, _ = fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, preValue) @@ -56,7 +79,7 @@ func PromptDefault(prompt, name, preValue string, validators ...PromptValidator) } if complaint != "" { _, _ = fmt.Fprintln(os.Stderr, complaint) - continue + continue loop } } @@ -75,6 +98,7 @@ func PromptPassword(prompt, name string, validators ...PromptValidator) (string, }) defer cancel() +loop: for { _, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt) @@ -96,7 +120,7 @@ func PromptPassword(prompt, name string, validators ...PromptValidator) (string, } if complaint != "" { _, _ = fmt.Fprintln(os.Stderr, complaint) - continue + continue loop } } @@ -121,10 +145,116 @@ func PromptChoice(prompt string, choices []string) (int, error) { index, err := strconv.Atoi(line) if err != nil || index < 1 || index > len(choices) { - fmt.Println("invalid input") + _, _ = fmt.Fprintln(os.Stderr, "invalid input") + continue + } + + return index - 1, nil + } +} + +func PromptURLWithRemote(prompt, name string, validRemotes []string, validators ...PromptValidator) (string, error) { + if len(validRemotes) == 0 { + return Prompt(prompt, name, validators...) + } + + sort.Strings(validRemotes) + + for { + _, _ = fmt.Fprintln(os.Stderr, "\nDetected projects:") + + for i, remote := range validRemotes { + _, _ = fmt.Fprintf(os.Stderr, "[%d]: %v\n", i+1, remote) + } + + _, _ = fmt.Fprintf(os.Stderr, "\n[0]: Another project\n\n") + _, _ = fmt.Fprintf(os.Stderr, "Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimSpace(line) + + index, err := strconv.Atoi(line) + if err != nil || index < 0 || index > len(validRemotes) { + _, _ = fmt.Fprintln(os.Stderr, "invalid input") + continue + } + + // if user want to enter another project url break this loop + if index == 0 { + break + } + + return validRemotes[index-1], nil + } + + return Prompt(prompt, name, validators...) +} + +func PromptCredential(target, name string, credentials []auth.Credential, choices []string) (auth.Credential, int, error) { + if len(credentials) == 0 && len(choices) == 0 { + return nil, 0, fmt.Errorf("no possible choice") + } + if len(credentials) == 0 && len(choices) == 1 { + return nil, 0, nil + } + + sort.Sort(auth.ById(credentials)) + + for { + _, _ = fmt.Fprintln(os.Stderr) + + offset := 0 + for i, choice := range choices { + _, _ = fmt.Fprintf(os.Stderr, "[%d]: %s\n", i+1, choice) + offset++ + } + + if len(credentials) > 0 { + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Existing %s for %s:\n", name, target) + + for i, cred := range credentials { + meta := make([]string, 0, len(cred.Metadata())) + for k, v := range cred.Metadata() { + meta = append(meta, k+":"+v) + } + sort.Strings(meta) + metaFmt := strings.Join(meta, ",") + + fmt.Printf("[%d]: %s => (%s) (%s)\n", + i+1+offset, + colors.Cyan(cred.ID().Human()), + metaFmt, + cred.CreateTime().Format(time.RFC822), + ) + } + } + + _, _ = fmt.Fprintln(os.Stderr) + _, _ = fmt.Fprintf(os.Stderr, "Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + _, _ = fmt.Fprintln(os.Stderr) + if err != nil { + return nil, 0, err + } + + line = strings.TrimSpace(line) + index, err := strconv.Atoi(line) + if err != nil || index < 1 || index > len(choices)+len(credentials) { + _, _ = fmt.Fprintln(os.Stderr, "invalid input") continue } - return index, nil + switch { + case index <= len(choices): + return nil, index - 1, nil + default: + return credentials[index-len(choices)-1], 0, nil + } } } diff --git a/misc/bash_completion/git-bug b/misc/bash_completion/git-bug index a062bfe8..bbebd28f 100644 --- a/misc/bash_completion/git-bug +++ b/misc/bash_completion/git-bug @@ -39,6 +39,7 @@ __git-bug_contains_word() __git-bug_handle_reply() { __git-bug_debug "${FUNCNAME[0]}" + local comp case $cur in -*) if [[ $(type -t compopt) = "builtin" ]]; then @@ -50,7 +51,9 @@ __git-bug_handle_reply() else allflags=("${flags[*]} ${two_word_flags[*]}") fi - COMPREPLY=( $(compgen -W "${allflags[*]}" -- "$cur") ) + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${allflags[*]}" -- "$cur") if [[ $(type -t compopt) = "builtin" ]]; then [[ "${COMPREPLY[0]}" == *= ]] || compopt +o nospace fi @@ -100,10 +103,14 @@ __git-bug_handle_reply() if [[ ${#must_have_one_flag[@]} -ne 0 ]]; then completions+=("${must_have_one_flag[@]}") fi - COMPREPLY=( $(compgen -W "${completions[*]}" -- "$cur") ) + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${completions[*]}" -- "$cur") if [[ ${#COMPREPLY[@]} -eq 0 && ${#noun_aliases[@]} -gt 0 && ${#must_have_one_noun[@]} -ne 0 ]]; then - COMPREPLY=( $(compgen -W "${noun_aliases[*]}" -- "$cur") ) + while IFS='' read -r comp; do + COMPREPLY+=("$comp") + done < <(compgen -W "${noun_aliases[*]}" -- "$cur") fi if [[ ${#COMPREPLY[@]} -eq 0 ]]; then @@ -138,7 +145,7 @@ __git-bug_handle_filename_extension_flag() __git-bug_handle_subdirs_in_dir_flag() { local dir="$1" - pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 + pushd "${dir}" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return } __git-bug_handle_flag() @@ -412,10 +419,10 @@ _git-bug_bridge_configure() two_word_flags+=("--base-url") two_word_flags+=("-b") local_nonpersistent_flags+=("--base-url=") - flags+=("--owner=") - two_word_flags+=("--owner") - two_word_flags+=("-o") - local_nonpersistent_flags+=("--owner=") + flags+=("--login=") + two_word_flags+=("--login") + two_word_flags+=("-l") + local_nonpersistent_flags+=("--login=") flags+=("--credential=") two_word_flags+=("--credential") two_word_flags+=("-c") @@ -425,6 +432,10 @@ _git-bug_bridge_configure() local_nonpersistent_flags+=("--token=") flags+=("--token-stdin") local_nonpersistent_flags+=("--token-stdin") + flags+=("--owner=") + two_word_flags+=("--owner") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--owner=") flags+=("--project=") two_word_flags+=("--project") two_word_flags+=("-p") diff --git a/misc/powershell_completion/git-bug b/misc/powershell_completion/git-bug index b15e6398..59d2bf12 100644 --- a/misc/powershell_completion/git-bug +++ b/misc/powershell_completion/git-bug @@ -62,8 +62,8 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock { break } '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('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]') + [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,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') @@ -79,20 +79,22 @@ Register-ArgumentCompleter -Native -CommandName 'git-bug' -ScriptBlock { 'git-bug;bridge;configure' { [CompletionResult]::new('-n', 'n', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge') [CompletionResult]::new('--name', 'name', [CompletionResultType]::ParameterName, 'A distinctive name to identify the bridge') - [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,launchpad-preview]') - [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,launchpad-preview]') - [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the target repository') - [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the target repository') - [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your issue tracker service') - [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your issue tracker service') - [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the target repository') - [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the target repository') - [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")') - [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")') - [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the API') + [CompletionResult]::new('-t', 't', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]') + [CompletionResult]::new('--target', 'target', [CompletionResultType]::ParameterName, 'The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]') + [CompletionResult]::new('-u', 'u', [CompletionResultType]::ParameterName, 'The URL of the remote repository') + [CompletionResult]::new('--url', 'url', [CompletionResultType]::ParameterName, 'The URL of the remote repository') + [CompletionResult]::new('-b', 'b', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker') + [CompletionResult]::new('--base-url', 'base-url', [CompletionResultType]::ParameterName, 'The base URL of your remote issue tracker') + [CompletionResult]::new('-l', 'l', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker') + [CompletionResult]::new('--login', 'login', [CompletionResultType]::ParameterName, 'The login on your remote issue tracker') + [CompletionResult]::new('-c', 'c', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")') + [CompletionResult]::new('--credential', 'credential', [CompletionResultType]::ParameterName, 'The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")') + [CompletionResult]::new('--token', 'token', [CompletionResultType]::ParameterName, 'A raw authentication token for the remote issue tracker') [CompletionResult]::new('--token-stdin', 'token-stdin', [CompletionResultType]::ParameterName, 'Will read the token from stdin and ignore --token') - [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the target repository') - [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the target repository') + [CompletionResult]::new('-o', 'o', [CompletionResultType]::ParameterName, 'The owner of the remote repository') + [CompletionResult]::new('--owner', 'owner', [CompletionResultType]::ParameterName, 'The owner of the remote repository') + [CompletionResult]::new('-p', 'p', [CompletionResultType]::ParameterName, 'The name of the remote repository') + [CompletionResult]::new('--project', 'project', [CompletionResultType]::ParameterName, 'The name of the remote repository') break } 'git-bug;bridge;pull' { diff --git a/misc/zsh_completion/git-bug b/misc/zsh_completion/git-bug index f6d50e08..d28bd244 100644 --- a/misc/zsh_completion/git-bug +++ b/misc/zsh_completion/git-bug @@ -177,7 +177,7 @@ 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,jira,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]:' } @@ -193,14 +193,15 @@ function _git-bug_bridge_auth_show { function _git-bug_bridge_configure { _arguments \ '(-n --name)'{-n,--name}'[A distinctive name to identify the bridge]:' \ - '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,launchpad-preview]]:' \ - '(-u --url)'{-u,--url}'[The URL of the target repository]:' \ - '(-b --base-url)'{-b,--base-url}'[The base URL of your issue tracker service]:' \ - '(-o --owner)'{-o,--owner}'[The owner of the target repository]:' \ - '(-c --credential)'{-c,--credential}'[The identifier or prefix of an already known credential for the API (see "git-bug bridge auth")]:' \ - '--token[A raw authentication token for the API]:' \ + '(-t --target)'{-t,--target}'[The target of the bridge. Valid values are [github,gitlab,jira,launchpad-preview]]:' \ + '(-u --url)'{-u,--url}'[The URL of the remote repository]:' \ + '(-b --base-url)'{-b,--base-url}'[The base URL of your remote issue tracker]:' \ + '(-l --login)'{-l,--login}'[The login on your remote issue tracker]:' \ + '(-c --credential)'{-c,--credential}'[The identifier or prefix of an already known credential for your remote issue tracker (see "git-bug bridge auth")]:' \ + '--token[A raw authentication token for the remote issue tracker]:' \ '--token-stdin[Will read the token from stdin and ignore --token]' \ - '(-p --project)'{-p,--project}'[The name of the target repository]:' + '(-o --owner)'{-o,--owner}'[The owner of the remote repository]:' \ + '(-p --project)'{-p,--project}'[The name of the remote repository]:' } function _git-bug_bridge_pull { |