aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--README.md46
-rw-r--r--bridge/bridges.go2
-rw-r--r--bridge/core/auth/credential.go53
-rw-r--r--bridge/core/auth/credential_base.go90
-rw-r--r--bridge/core/auth/credential_test.go24
-rw-r--r--bridge/core/auth/login.go67
-rw-r--r--bridge/core/auth/login_password.go76
-rw-r--r--bridge/core/auth/login_password_test.go14
-rw-r--r--bridge/core/auth/login_test.go13
-rw-r--r--bridge/core/auth/options.go11
-rw-r--r--bridge/core/auth/token.go84
-rw-r--r--bridge/core/auth/token_test.go13
-rw-r--r--bridge/core/bridge.go65
-rw-r--r--bridge/core/config.go5
-rw-r--r--bridge/core/export.go6
-rw-r--r--bridge/core/import.go6
-rw-r--r--bridge/core/interfaces.go7
-rw-r--r--bridge/core/params.go36
-rw-r--r--bridge/github/config.go228
-rw-r--r--bridge/github/config_test.go4
-rw-r--r--bridge/github/export.go18
-rw-r--r--bridge/github/export_test.go17
-rw-r--r--bridge/github/github.go4
-rw-r--r--bridge/github/import.go4
-rw-r--r--bridge/github/import_test.go11
-rw-r--r--bridge/gitlab/config.go223
-rw-r--r--bridge/gitlab/export.go16
-rw-r--r--bridge/gitlab/export_test.go17
-rw-r--r--bridge/gitlab/gitlab.go10
-rw-r--r--bridge/gitlab/import.go12
-rw-r--r--bridge/gitlab/import_test.go11
-rw-r--r--bridge/jira/client.go1461
-rw-r--r--bridge/jira/config.go192
-rw-r--r--bridge/jira/export.go475
-rw-r--r--bridge/jira/import.go655
-rw-r--r--bridge/jira/jira.go143
-rw-r--r--bridge/launchpad/config.go28
-rw-r--r--bridge/launchpad/import.go2
-rw-r--r--bridge/launchpad/launchpad.go4
-rw-r--r--bug/op_edit_comment.go12
-rw-r--r--cache/bug_cache.go22
-rw-r--r--commands/bridge_auth_addtoken.go2
-rw-r--r--commands/bridge_configure.go22
-rw-r--r--doc/jira_bridge.md377
-rw-r--r--doc/man/git-bug-add.113
-rw-r--r--doc/man/git-bug-bridge-auth-add-token.115
-rw-r--r--doc/man/git-bug-bridge-auth-rm.19
-rw-r--r--doc/man/git-bug-bridge-auth-show.17
-rw-r--r--doc/man/git-bug-bridge-auth.17
-rw-r--r--doc/man/git-bug-bridge-configure.138
-rw-r--r--doc/man/git-bug-bridge-pull.113
-rw-r--r--doc/man/git-bug-bridge-push.19
-rw-r--r--doc/man/git-bug-bridge-rm.19
-rw-r--r--doc/man/git-bug-bridge.17
-rw-r--r--doc/man/git-bug-commands.111
-rw-r--r--doc/man/git-bug-comment-add.113
-rw-r--r--doc/man/git-bug-comment.19
-rw-r--r--doc/man/git-bug-deselect.17
-rw-r--r--doc/man/git-bug-label-add.19
-rw-r--r--doc/man/git-bug-label-rm.19
-rw-r--r--doc/man/git-bug-label.19
-rw-r--r--doc/man/git-bug-ls-id.19
-rw-r--r--doc/man/git-bug-ls-label.17
-rw-r--r--doc/man/git-bug-ls.127
-rw-r--r--doc/man/git-bug-pull.19
-rw-r--r--doc/man/git-bug-push.19
-rw-r--r--doc/man/git-bug-select.111
-rw-r--r--doc/man/git-bug-show.111
-rw-r--r--doc/man/git-bug-status-close.19
-rw-r--r--doc/man/git-bug-status-open.19
-rw-r--r--doc/man/git-bug-status.19
-rw-r--r--doc/man/git-bug-termui.17
-rw-r--r--doc/man/git-bug-title-edit.111
-rw-r--r--doc/man/git-bug-title.19
-rw-r--r--doc/man/git-bug-user-adopt.19
-rw-r--r--doc/man/git-bug-user-create.17
-rw-r--r--doc/man/git-bug-user-ls.17
-rw-r--r--doc/man/git-bug-user.111
-rw-r--r--doc/man/git-bug-version.113
-rw-r--r--doc/man/git-bug-webui.113
-rw-r--r--doc/man/git-bug.17
-rw-r--r--doc/md/git-bug_bridge_auth_add-token.md2
-rw-r--r--doc/md/git-bug_bridge_configure.md22
-rw-r--r--go.mod8
-rw-r--r--go.sum107
-rw-r--r--identity/bare.go2
-rw-r--r--input/prompt.go138
-rw-r--r--misc/bash_completion/git-bug27
-rw-r--r--misc/powershell_completion/git-bug32
-rw-r--r--misc/zsh_completion/git-bug17
90 files changed, 4515 insertions, 826 deletions
diff --git a/README.md b/README.md
index f9960b07..eb995ac0 100644
--- a/README.md
+++ b/README.md
@@ -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
```
diff --git a/go.mod b/go.mod
index b48d402d..d23e70b5 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 20f4f187..6ebc3f97 100644
--- a/go.sum
+++ b/go.sum
@@ -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 {