diff options
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | bridge/core/bridge.go | 13 | ||||
-rw-r--r-- | bridge/core/interfaces.go | 2 | ||||
-rw-r--r-- | bridge/github/config.go | 406 | ||||
-rw-r--r-- | bridge/github/config_test.go | 209 | ||||
-rw-r--r-- | bridge/github/github.go | 3 | ||||
-rw-r--r-- | bridge/github/import.go | 5 | ||||
-rw-r--r-- | bridge/github/import_test.go | 10 | ||||
-rw-r--r-- | bridge/github/iterator.go | 8 | ||||
-rw-r--r-- | bridge/launchpad/config.go | 79 | ||||
-rw-r--r-- | bridge/launchpad/config_test.go | 93 | ||||
-rw-r--r-- | cache/repo_cache.go | 7 | ||||
-rw-r--r-- | commands/bridge_configure.go | 103 | ||||
-rw-r--r-- | doc/man/git-bug-bridge-configure.1 | 87 | ||||
-rw-r--r-- | doc/md/git-bug_bridge_configure.md | 60 | ||||
-rw-r--r-- | misc/bash_completion/git-bug | 24 | ||||
-rw-r--r-- | misc/zsh_completion/git-bug | 2 | ||||
-rw-r--r-- | repository/git.go | 22 | ||||
-rw-r--r-- | repository/mock_repo.go | 7 | ||||
-rw-r--r-- | repository/repo.go | 3 |
20 files changed, 1034 insertions, 111 deletions
@@ -22,7 +22,7 @@ install: go install -ldflags "$(LDFLAGS)" . test: - go test -bench=. ./... + go test -v -bench=. ./... pack-webui: npm run --prefix webui build diff --git a/bridge/core/bridge.go b/bridge/core/bridge.go index 42730bf6..3513b790 100644 --- a/bridge/core/bridge.go +++ b/bridge/core/bridge.go @@ -21,6 +21,15 @@ const bridgeConfigKeyPrefix = "git-bug.bridge" var bridgeImpl map[string]reflect.Type +// BridgeParams holds parameters to simplify the bridge configuration without +// having to make terminal prompts. +type BridgeParams struct { + Owner string + Project string + URL string + Token string +} + // Bridge is a wrapper around a BridgeImpl that will bind low-level // implementation with utility code to provide high-level functions. type Bridge struct { @@ -169,8 +178,8 @@ func RemoveBridge(repo repository.RepoCommon, fullName string) error { } // Configure run the target specific configuration process -func (b *Bridge) Configure() error { - conf, err := b.impl.Configure(b.repo) +func (b *Bridge) Configure(params BridgeParams) error { + conf, err := b.impl.Configure(b.repo, params) if err != nil { return err } diff --git a/bridge/core/interfaces.go b/bridge/core/interfaces.go index be5afa62..37fdb3d7 100644 --- a/bridge/core/interfaces.go +++ b/bridge/core/interfaces.go @@ -15,7 +15,7 @@ type BridgeImpl interface { // Configure handle the user interaction and return a key/value configuration // for future use - Configure(repo repository.RepoCommon) (Configuration, error) + Configure(repo repository.RepoCommon, params BridgeParams) (Configuration, error) // ValidateConfig check the configuration for error ValidateConfig(conf Configuration) error diff --git a/bridge/github/config.go b/bridge/github/config.go index 2a3119a6..707b3e2f 100644 --- a/bridge/github/config.go +++ b/bridge/github/config.go @@ -11,91 +11,101 @@ import ( "net/http" "os" "regexp" + "strconv" "strings" "syscall" "time" + "github.com/pkg/errors" + + "golang.org/x/crypto/ssh/terminal" + "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/repository" - "golang.org/x/crypto/ssh/terminal" ) const ( githubV3Url = "https://api.github.com" - keyUser = "user" + keyOwner = "owner" keyProject = "project" keyToken = "token" -) -func (*Github) Configure(repo repository.RepoCommon) (core.Configuration, error) { - conf := make(core.Configuration) + defaultTimeout = 60 * time.Second +) - fmt.Println() - fmt.Println("git-bug will now generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the repository git config.") - fmt.Println() - fmt.Println("The token will have the following scopes:") - fmt.Println(" - user:email: to be able to read public-only users email") - // fmt.Println("The token will have the \"repo\" permission, giving it read/write access to your repositories and issues. There is no narrower scope available, sorry :-|") - fmt.Println() +var ( + ErrBadProjectURL = errors.New("bad project url") +) - projectUser, projectName, err := promptURL() - if err != nil { - return nil, err - } +func (*Github) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) { + conf := make(core.Configuration) + var err error + var token string + var owner string + var project string + + // getting owner and project name + if params.Owner != "" && params.Project != "" { + // first try to use params if both or project and owner are provided + owner = params.Owner + project = params.Project + + } else if params.URL != "" { + // try to parse params URL and extract owner and project + owner, project, err = splitURL(params.URL) + if err != nil { + return nil, err + } - conf[keyUser] = projectUser - conf[keyProject] = projectName + } else { + // remote suggestions + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } - username, err := promptUsername() - if err != nil { - return nil, err + // terminal prompt + owner, project, err = promptURL(remotes) + if err != nil { + return nil, err + } } - password, err := promptPassword() + // validate project owner + ok, err := validateUsername(owner) if err != nil { return nil, err } - - // Attempt to authenticate and create a token - - note := fmt.Sprintf("git-bug - %s/%s", projectUser, projectName) - - resp, err := requestToken(note, username, password) - if err != nil { - return nil, err + if !ok { + return nil, fmt.Errorf("invalid parameter owner: %v", owner) } - defer resp.Body.Close() - - // Handle 2FA is needed - OTPHeader := resp.Header.Get("X-GitHub-OTP") - if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" { - otpCode, err := prompt2FA() - if err != nil { - return nil, err - } + // try to get token from params if provided, else use terminal prompt to either + // enter a token or login and generate a new one + if params.Token != "" { + token = params.Token - resp, err = requestTokenWith2FA(note, username, password, otpCode) + } else { + token, err = promptTokenOptions(owner, project) if err != nil { return nil, err } - - defer resp.Body.Close() } - if resp.StatusCode == http.StatusCreated { - token, err := decodeBody(resp.Body) - if err != nil { - return nil, err - } - conf[keyToken] = token - return conf, nil + // verify access to the repository with token + ok, err = validateProject(owner, project, token) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope") } - b, _ := ioutil.ReadAll(resp.Body) - fmt.Printf("Error %v: %v\n", resp.StatusCode, string(b)) + conf[keyToken] = token + conf[keyOwner] = owner + conf[keyProject] = project - return nil, nil + return conf, nil } func (*Github) ValidateConfig(conf core.Configuration) error { @@ -103,8 +113,8 @@ func (*Github) ValidateConfig(conf core.Configuration) error { return fmt.Errorf("missing %s key", keyToken) } - if _, ok := conf[keyUser]; !ok { - return fmt.Errorf("missing %s key", keyUser) + if _, ok := conf[keyOwner]; !ok { + return fmt.Errorf("missing %s key", keyOwner) } if _, ok := conf[keyProject]; !ok { @@ -114,20 +124,18 @@ func (*Github) ValidateConfig(conf core.Configuration) error { return nil } -func requestToken(note, username, password string) (*http.Response, error) { - return requestTokenWith2FA(note, username, password, "") +func requestToken(note, username, password string, scope string) (*http.Response, error) { + return requestTokenWith2FA(note, username, password, "", scope) } -func requestTokenWith2FA(note, username, password, otpCode string) (*http.Response, error) { +func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) { url := fmt.Sprintf("%s/authorizations", githubV3Url) params := struct { Scopes []string `json:"scopes"` Note string `json:"note"` Fingerprint string `json:"fingerprint"` }{ - // user:email is requested to be able to read public emails - // - a private email will stay private, even with this token - Scopes: []string{"user:email"}, + Scopes: []string{scope}, Note: note, Fingerprint: randomFingerprint(), } @@ -149,7 +157,9 @@ func requestTokenWith2FA(note, username, password, otpCode string) (*http.Respon req.Header.Set("X-GitHub-OTP", otpCode) } - client := http.Client{} + client := &http.Client{ + Timeout: defaultTimeout, + } return client.Do(req) } @@ -184,6 +194,139 @@ func randomFingerprint() string { return string(b) } +func promptTokenOptions(owner, project string) (string, error) { + for { + fmt.Println() + fmt.Println("[1]: user provided token") + fmt.Println("[2]: interactive token creation") + fmt.Print("Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Println() + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index != 1 && index != 2) { + fmt.Println("invalid input") + continue + } + + if index == 1 { + return promptToken() + } + + return loginAndRequestToken(owner, project) + } +} + +func promptToken() (string, error) { + fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.") + fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.") + fmt.Println() + fmt.Println("The access scope depend on the type of repository.") + fmt.Println("Public:") + fmt.Println(" - 'public_repo': to be able to read public repositories") + fmt.Println("Private:") + 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()) + } + + for { + fmt.Print("Enter token: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + token := strings.TrimRight(line, "\n") + if re.MatchString(token) { + return token, nil + } + + fmt.Println("token is invalid") + } +} + +func loginAndRequestToken(owner, project string) (string, error) { + fmt.Println("git-bug will now generate an access token in your Github profile. Your credential are not stored and are only used to generate the token. The token is stored in the repository git config.") + fmt.Println() + fmt.Println("The access scope depend on the type of repository.") + fmt.Println("Public:") + fmt.Println(" - 'public_repo': to be able to read public repositories") + fmt.Println("Private:") + fmt.Println(" - 'repo' : to be able to read private repositories") + fmt.Println() + + // prompt project visibility to know the token scope needed for the repository + isPublic, err := promptProjectVisibility() + if err != nil { + return "", err + } + + username, err := promptUsername() + if err != nil { + return "", err + } + + password, err := promptPassword() + 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, username, password, scope) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + // Handle 2FA is needed + OTPHeader := resp.Header.Get("X-GitHub-OTP") + if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" { + otpCode, err := prompt2FA() + if err != nil { + return "", err + } + + resp, err = requestTokenWith2FA(note, username, password, otpCode, scope) + if err != nil { + return "", err + } + + defer resp.Body.Close() + } + + if resp.StatusCode == http.StatusCreated { + return decodeBody(resp.Body) + } + + b, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b)) +} + func promptUsername() (string, error) { for { fmt.Print("username: ") @@ -207,7 +350,45 @@ func promptUsername() (string, error) { } } -func promptURL() (string, string, error) { +func promptURL(remotes map[string]string) (string, string, error) { + 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.TrimRight(line, "\n") + + 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: ") @@ -217,42 +398,93 @@ func promptURL() (string, string, error) { } line = strings.TrimRight(line, "\n") - if line == "" { fmt.Println("URL is empty") continue } - projectUser, projectName, err := splitURL(line) - + // get owner and project from url + owner, project, err := splitURL(line) if err != nil { fmt.Println(err) continue } - return projectUser, projectName, nil + return owner, project, nil } } -func splitURL(url string) (string, string, error) { - re, err := regexp.Compile(`github\.com\/([^\/]*)\/([^\/]*)`) +// splitURL extract the owner and project from a github repository URL. It will remove the +// '.git' extension from the URL before parsing it. +// Note that Github removes the '.git' extension from projects names at their creation +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(err) + panic("regexp compile:" + err.Error()) } - res := re.FindStringSubmatch(url) - + res := re.FindStringSubmatch(cleanURL) if res == nil { - return "", "", fmt.Errorf("bad github project url") + return "", "", ErrBadProjectURL + } + + owner = res[1] + project = res[2] + return +} + +func getValidGithubRemoteURLs(remotes map[string]string) []string { + urls := make([]string, 0, len(remotes)) + for _, url := range remotes { + // split url can work again with shortURL + owner, project, err := splitURL(url) + if err == nil { + shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project) + urls = append(urls, shortURL) + } } - return res[1], res[2], nil + return urls } func validateUsername(username string) (bool, error) { url := fmt.Sprintf("%s/users/%s", githubV3Url, username) - resp, err := http.Get(url) + client := &http.Client{ + Timeout: defaultTimeout, + } + + resp, err := client.Get(url) + if err != nil { + return false, err + } + + err = resp.Body.Close() + if err != nil { + return false, err + } + + return resp.StatusCode == http.StatusOK, nil +} + +func validateProject(owner, project, token string) (bool, error) { + url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + + // need the token for private repositories + req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) + + client := &http.Client{ + Timeout: defaultTimeout, + } + + resp, err := client.Do(req) if err != nil { return false, err } @@ -291,6 +523,7 @@ func prompt2FA() (string, error) { fmt.Print("two-factor authentication code: ") byte2fa, err := terminal.ReadPassword(int(syscall.Stdin)) + fmt.Println() if err != nil { return "", err } @@ -302,3 +535,28 @@ func prompt2FA() (string, error) { fmt.Println("code is empty") } } + +func promptProjectVisibility() (bool, error) { + for { + fmt.Println("[1]: public") + fmt.Println("[2]: private") + fmt.Print("repository visibility: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Println() + if err != nil { + return false, err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index != 0 && index != 1) { + fmt.Println("invalid input") + continue + } + + // return true for public repositories, false for private + return index == 0, nil + } +} diff --git a/bridge/github/config_test.go b/bridge/github/config_test.go new file mode 100644 index 00000000..4feeaa74 --- /dev/null +++ b/bridge/github/config_test.go @@ -0,0 +1,209 @@ +package github + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitURL(t *testing.T) { + type args struct { + url string + } + type want struct { + owner string + project string + err error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "default url", + args: args{ + url: "https://github.com/MichaelMure/git-bug", + }, + want: want{ + owner: "MichaelMure", + project: "git-bug", + err: nil, + }, + }, + { + name: "default issues url", + args: args{ + url: "https://github.com/MichaelMure/git-bug/issues", + }, + want: want{ + owner: "MichaelMure", + project: "git-bug", + err: nil, + }, + }, + { + name: "default url with git extension", + args: args{ + url: "https://github.com/MichaelMure/git-bug.git", + }, + want: want{ + owner: "MichaelMure", + project: "git-bug", + err: nil, + }, + }, + { + name: "url with git protocol", + args: args{ + url: "git://github.com/MichaelMure/git-bug.git", + }, + want: want{ + owner: "MichaelMure", + project: "git-bug", + err: nil, + }, + }, + { + name: "ssh url", + args: args{ + url: "git@github.com:MichaelMure/git-bug.git", + }, + want: want{ + owner: "MichaelMure", + project: "git-bug", + err: nil, + }, + }, + { + name: "bad url", + args: args{ + url: "https://githb.com/MichaelMure/git-bug.git", + }, + want: want{ + err: ErrBadProjectURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + owner, project, err := splitURL(tt.args.url) + assert.Equal(t, tt.want.err, err) + assert.Equal(t, tt.want.owner, owner) + assert.Equal(t, tt.want.project, project) + }) + } +} + +func TestValidateUsername(t *testing.T) { + if env := os.Getenv("TRAVIS"); env == "true" { + t.Skip("Travis environment: avoiding non authenticated requests") + } + + type args struct { + username string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "existing username", + args: args{ + username: "MichaelMure", + }, + want: true, + }, + { + name: "existing organisation name", + args: args{ + username: "ipfs", + }, + want: true, + }, + { + name: "non existing username", + args: args{ + username: "cant-find-this", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, _ := validateUsername(tt.args.username) + assert.Equal(t, tt.want, ok) + }) + } +} + +func TestValidateProject(t *testing.T) { + tokenPrivateScope := os.Getenv("GITHUB_TOKEN_PRIVATE") + if tokenPrivateScope == "" { + t.Skip("Env var GITHUB_TOKEN_PRIVATE missing") + } + + tokenPublicScope := os.Getenv("GITHUB_TOKEN_PUBLIC") + if tokenPublicScope == "" { + t.Skip("Env var GITHUB_TOKEN_PUBLIC missing") + } + + type args struct { + owner string + project string + token string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "public repository and token with scope 'public_repo'", + args: args{ + project: "git-bug", + owner: "MichaelMure", + token: tokenPublicScope, + }, + want: true, + }, + { + name: "private repository and token with scope 'repo'", + args: args{ + project: "git-bug-test-github-bridge", + owner: "MichaelMure", + token: tokenPrivateScope, + }, + want: true, + }, + { + name: "private repository and token with scope 'public_repo'", + args: args{ + project: "git-bug-test-github-bridge", + owner: "MichaelMure", + token: tokenPublicScope, + }, + want: false, + }, + { + name: "project not existing", + args: args{ + project: "cant-find-this", + owner: "organisation-not-found", + token: tokenPublicScope, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, _ := validateProject(tt.args.owner, tt.args.project, tt.args.token) + assert.Equal(t, tt.want, ok) + }) + } +} diff --git a/bridge/github/github.go b/bridge/github/github.go index 5fee7487..3e717ee9 100644 --- a/bridge/github/github.go +++ b/bridge/github/github.go @@ -4,9 +4,10 @@ package github import ( "context" - "github.com/MichaelMure/git-bug/bridge/core" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" + + "github.com/MichaelMure/git-bug/bridge/core" ) func init() { diff --git a/bridge/github/import.go b/bridge/github/import.go index 2b9e5561..edb97c4f 100644 --- a/bridge/github/import.go +++ b/bridge/github/import.go @@ -5,13 +5,14 @@ import ( "fmt" "time" + "github.com/shurcooL/githubv4" + "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/bug" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/identity" "github.com/MichaelMure/git-bug/util/git" "github.com/MichaelMure/git-bug/util/text" - "github.com/shurcooL/githubv4" ) const ( @@ -42,7 +43,7 @@ func (gi *githubImporter) Init(conf core.Configuration) error { // 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(repo *cache.RepoCache, since time.Time) error { - gi.iterator = NewIterator(gi.conf[keyUser], gi.conf[keyProject], gi.conf[keyToken], since) + gi.iterator = NewIterator(gi.conf[keyOwner], gi.conf[keyProject], gi.conf[keyToken], since) // Loop over all matching issues for gi.iterator.NextIssue() { diff --git a/bridge/github/import_test.go b/bridge/github/import_test.go index 1e31501b..24356f34 100644 --- a/bridge/github/import_test.go +++ b/bridge/github/import_test.go @@ -133,16 +133,16 @@ func Test_Importer(t *testing.T) { defer backend.Close() interrupt.RegisterCleaner(backend.Close) - token := os.Getenv("GITHUB_TOKEN") + token := os.Getenv("GITHUB_TOKEN_PRIVATE") if token == "" { - t.Skip("Env var GITHUB_TOKEN missing") + t.Skip("Env var GITHUB_TOKEN_PRIVATE missing") } importer := &githubImporter{} err = importer.Init(core.Configuration{ - "user": "MichaelMure", - "project": "git-bug-test-github-bridge", - "token": token, + keyOwner: "MichaelMure", + keyProject: "git-bug-test-github-bridge", + keyToken: token, }) require.NoError(t, err) diff --git a/bridge/github/iterator.go b/bridge/github/iterator.go index 5935276a..fcf72b8f 100644 --- a/bridge/github/iterator.go +++ b/bridge/github/iterator.go @@ -60,7 +60,7 @@ type iterator struct { } // NewIterator create and initalize a new iterator -func NewIterator(user, project, token string, since time.Time) *iterator { +func NewIterator(owner, project, token string, since time.Time) *iterator { i := &iterator{ gc: buildClient(token), since: since, @@ -70,21 +70,21 @@ func NewIterator(user, project, token string, since time.Time) *iterator { issueEdit: indexer{-1}, commentEdit: indexer{-1}, variables: map[string]interface{}{ - "owner": githubv4.String(user), + "owner": githubv4.String(owner), "name": githubv4.String(project), }, }, commentEdit: commentEditIterator{ index: -1, variables: map[string]interface{}{ - "owner": githubv4.String(user), + "owner": githubv4.String(owner), "name": githubv4.String(project), }, }, issueEdit: issueEditIterator{ index: -1, variables: map[string]interface{}{ - "owner": githubv4.String(user), + "owner": githubv4.String(owner), "name": githubv4.String(project), }, }, diff --git a/bridge/launchpad/config.go b/bridge/launchpad/config.go index 11a465be..d8efea46 100644 --- a/bridge/launchpad/config.go +++ b/bridge/launchpad/config.go @@ -2,26 +2,65 @@ package launchpad import ( "bufio" + "errors" "fmt" + "net/http" "os" + "regexp" "strings" + "time" "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/repository" ) -const keyProject = "project" +var ErrBadProjectURL = errors.New("bad Launchpad project URL") + +const ( + keyProject = "project" + defaultTimeout = 60 * time.Second +) + +func (*Launchpad) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) { + if params.Token != "" { + fmt.Println("warning: --token is ineffective for a Launchpad bridge") + } + if params.Owner != "" { + fmt.Println("warning: --owner is ineffective for a Launchpad bridge") + } -func (*Launchpad) Configure(repo repository.RepoCommon) (core.Configuration, error) { conf := make(core.Configuration) + var err error + var project string + + if params.Project != "" { + project = params.Project - projectName, err := promptProjectName() + } else if params.URL != "" { + // get project name from url + project, err = splitURL(params.URL) + if err != nil { + return nil, err + } + + } else { + // get project name from terminal prompt + project, err = promptProjectName() + if err != nil { + return nil, err + } + } + + // verify project + ok, err := validateProject(project) if err != nil { return nil, err } + if !ok { + return nil, fmt.Errorf("project doesn't exist") + } - conf[keyProject] = projectName - + conf[keyProject] = project return conf, nil } @@ -52,3 +91,33 @@ func promptProjectName() (string, error) { return line, nil } } + +func validateProject(project string) (bool, error) { + url := fmt.Sprintf("%s/%s", apiRoot, project) + + client := &http.Client{ + Timeout: defaultTimeout, + } + + resp, err := client.Get(url) + if err != nil { + return false, err + } + + return resp.StatusCode == http.StatusOK, nil +} + +// 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()) + } + + res := re.FindStringSubmatch(url) + if res == nil { + return "", ErrBadProjectURL + } + + return res[1], nil +} diff --git a/bridge/launchpad/config_test.go b/bridge/launchpad/config_test.go new file mode 100644 index 00000000..275c0d24 --- /dev/null +++ b/bridge/launchpad/config_test.go @@ -0,0 +1,93 @@ +package launchpad + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitURL(t *testing.T) { + type args struct { + url string + } + type want struct { + project string + err error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "default project url", + args: args{ + url: "https://launchpad.net/ubuntu", + }, + want: want{ + project: "ubuntu", + err: nil, + }, + }, + { + name: "project bugs url", + args: args{ + url: "https://bugs.launchpad.net/ubuntu", + }, + want: want{ + project: "ubuntu", + err: nil, + }, + }, + { + name: "bad url", + args: args{ + url: "https://launchpa.net/ubuntu", + }, + want: want{ + err: ErrBadProjectURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + project, err := splitURL(tt.args.url) + assert.Equal(t, tt.want.err, err) + assert.Equal(t, tt.want.project, project) + }) + } +} + +func TestValidateProject(t *testing.T) { + type args struct { + project string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "public project", + args: args{ + project: "ubuntu", + }, + want: true, + }, + { + name: "non existing project", + args: args{ + project: "cant-find-this", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ok, _ := validateProject(tt.args.project) + assert.Equal(t, tt.want, ok) + }) + } +} diff --git a/cache/repo_cache.go b/cache/repo_cache.go index dc1889b2..b3b2ea1e 100644 --- a/cache/repo_cache.go +++ b/cache/repo_cache.go @@ -104,11 +104,16 @@ func (c *RepoCache) GetPath() string { return c.repo.GetPath() } -// GetPath returns the path to the repo. +// GetCoreEditor returns the name of the editor that the user has used to configure git. func (c *RepoCache) GetCoreEditor() (string, error) { return c.repo.GetCoreEditor() } +// GetRemotes returns the configured remotes repositories. +func (c *RepoCache) GetRemotes() (map[string]string, error) { + return c.repo.GetRemotes() +} + // GetUserName returns the name the the user has used to configure git func (c *RepoCache) GetUserName() (string, error) { return c.repo.GetUserName() diff --git a/commands/bridge_configure.go b/commands/bridge_configure.go index ce10d9af..61d969d1 100644 --- a/commands/bridge_configure.go +++ b/commands/bridge_configure.go @@ -6,11 +6,25 @@ import ( "os" "strconv" "strings" + "syscall" + + "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" "github.com/MichaelMure/git-bug/bridge" + "github.com/MichaelMure/git-bug/bridge/core" "github.com/MichaelMure/git-bug/cache" "github.com/MichaelMure/git-bug/util/interrupt" - "github.com/spf13/cobra" +) + +const ( + defaultName = "default" +) + +var ( + bridgeConfigureName string + bridgeConfigureTarget string + bridgeParams core.BridgeParams ) func runBridgeConfigure(cmd *cobra.Command, args []string) error { @@ -21,26 +35,40 @@ func runBridgeConfigure(cmd *cobra.Command, args []string) error { defer backend.Close() interrupt.RegisterCleaner(backend.Close) - target, err := promptTarget() + termState, err := terminal.GetState(int(syscall.Stdin)) if err != nil { return err } - name, err := promptName() - if err != nil { - return err + interrupt.RegisterCleaner(func() error { + return terminal.Restore(int(syscall.Stdin), termState) + }) + + if bridgeConfigureTarget == "" { + bridgeConfigureTarget, err = promptTarget() + if err != nil { + return err + } } - b, err := bridge.NewBridge(backend, target, name) + if bridgeConfigureName == "" { + bridgeConfigureName, err = promptName() + if err != nil { + return err + } + } + + b, err := bridge.NewBridge(backend, bridgeConfigureTarget, bridgeConfigureName) if err != nil { return err } - err = b.Configure() + err = b.Configure(bridgeParams) if err != nil { return err } + fmt.Printf("Successfully configured bridge: %s\n", bridgeConfigureName) return nil } @@ -54,6 +82,7 @@ func promptTarget() (string, error) { fmt.Printf("target: ") line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { return "", err } @@ -71,8 +100,6 @@ func promptTarget() (string, error) { } func promptName() (string, error) { - defaultName := "default" - fmt.Printf("name [%s]: ", defaultName) line, err := bufio.NewReader(os.Stdin).ReadString('\n') @@ -90,12 +117,66 @@ func promptName() (string, error) { } var bridgeConfigureCmd = &cobra.Command{ - Use: "configure", - Short: "Configure a new bridge.", + 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.`, + Example: `# Interactive example +[1]: github +[2]: launchpad-preview +target: 1 +name [default]: default + +Detected projects: +[1]: github.com/a-hilaly/git-bug +[2]: github.com/MichaelMure/git-bug + +[0]: Another project + +Select option: 1 + +[1]: user provided token +[2]: interactive token creation +Select option: 1 + +You can generate a new token by visiting https://github.com/settings/tokens. +Choose 'Generate new token' and set the necessary access scope for your repository. + +The access scope depend on the type of repository. +Public: + - 'public_repo': to be able to read public repositories +Private: + - 'repo' : to be able to read private repositories + +Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700 +Successfully configured bridge: default + +# For Github +git bug bridge configure \ + --name=default \ + --target=github \ + --owner=$(OWNER) \ + --project=$(PROJECT) \ + --token=$(TOKEN) + +# For Launchpad +git bug bridge configure \ + --name=default \ + --target=launchpad-preview \ + --url=https://bugs.launchpad.net/ubuntu/`, PreRunE: loadRepo, RunE: runBridgeConfigure, } func init() { bridgeCmd.AddCommand(bridgeConfigureCmd) + 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(&bridgeParams.URL, "url", "u", "", "The URL of the target repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Owner, "owner", "o", "", "The owner of the target repository") + bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Token, "token", "T", "", "The authentication token for the API") + bridgeConfigureCmd.Flags().StringVarP(&bridgeParams.Project, "project", "p", "", "The name of the target repository") + bridgeConfigureCmd.Flags().SortFlags = false } diff --git a/doc/man/git-bug-bridge-configure.1 b/doc/man/git-bug-bridge-configure.1 index 27dfeac0..fa6feed5 100644 --- a/doc/man/git-bug-bridge-configure.1 +++ b/doc/man/git-bug-bridge-configure.1 @@ -15,15 +15,100 @@ git\-bug\-bridge\-configure \- Configure a new bridge. .SH DESCRIPTION .PP -Configure a new bridge. +.RS + +.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 .SH OPTIONS .PP +\fB\-n\fP, \fB\-\-name\fP="" + A distinctive name to identify the bridge + +.PP +\fB\-t\fP, \fB\-\-target\fP="" + The target of the bridge. Valid values are [github,launchpad\-preview] + +.PP +\fB\-u\fP, \fB\-\-url\fP="" + The URL of the target repository + +.PP +\fB\-o\fP, \fB\-\-owner\fP="" + The owner of the target repository + +.PP +\fB\-T\fP, \fB\-\-token\fP="" + The authentication token for the API + +.PP +\fB\-p\fP, \fB\-\-project\fP="" + The name of the target repository + +.PP \fB\-h\fP, \fB\-\-help\fP[=false] help for configure +.SH EXAMPLE +.PP +.RS + +.nf +# Interactive example +[1]: github +[2]: launchpad\-preview +target: 1 +name [default]: default + +Detected projects: +[1]: github.com/a\-hilaly/git\-bug +[2]: github.com/MichaelMure/git\-bug + +[0]: Another project + +Select option: 1 + +[1]: user provided token +[2]: interactive token creation +Select option: 1 + +You can generate a new token by visiting https://github.com/settings/tokens. +Choose 'Generate new token' and set the necessary access scope for your repository. + +The access scope depend on the type of repository. +Public: + \- 'public\_repo': to be able to read public repositories +Private: + \- 'repo' : to be able to read private repositories + +Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700 +Successfully configured bridge: default + +# For Github +git bug bridge configure \\ + \-\-name=default \\ + \-\-target=github \\ + \-\-owner=$(OWNER) \\ + \-\-project=$(PROJECT) \\ + \-\-token=$(TOKEN) + +# For Launchpad +git bug bridge configure \\ + \-\-name=default \\ + \-\-target=launchpad\-preview \\ + \-\-url=https://bugs.launchpad.net/ubuntu/ + +.fi +.RE + + .SH SEE ALSO .PP \fBgit\-bug\-bridge(1)\fP diff --git a/doc/md/git-bug_bridge_configure.md b/doc/md/git-bug_bridge_configure.md index 63fbbbca..788b3986 100644 --- a/doc/md/git-bug_bridge_configure.md +++ b/doc/md/git-bug_bridge_configure.md @@ -4,16 +4,72 @@ Configure a new bridge. ### Synopsis -Configure a new bridge. + 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] ``` +### Examples + +``` +# Interactive example +[1]: github +[2]: launchpad-preview +target: 1 +name [default]: default + +Detected projects: +[1]: github.com/a-hilaly/git-bug +[2]: github.com/MichaelMure/git-bug + +[0]: Another project + +Select option: 1 + +[1]: user provided token +[2]: interactive token creation +Select option: 1 + +You can generate a new token by visiting https://github.com/settings/tokens. +Choose 'Generate new token' and set the necessary access scope for your repository. + +The access scope depend on the type of repository. +Public: + - 'public_repo': to be able to read public repositories +Private: + - 'repo' : to be able to read private repositories + +Enter token: 87cf5c03b64029f18ea5f9ca5679daa08ccbd700 +Successfully configured bridge: default + +# For Github +git bug bridge configure \ + --name=default \ + --target=github \ + --owner=$(OWNER) \ + --project=$(PROJECT) \ + --token=$(TOKEN) + +# For Launchpad +git bug bridge configure \ + --name=default \ + --target=launchpad-preview \ + --url=https://bugs.launchpad.net/ubuntu/ +``` + ### Options ``` - -h, --help help for configure + -n, --name string A distinctive name to identify the bridge + -t, --target string The target of the bridge. Valid values are [github,launchpad-preview] + -u, --url string The URL of the target repository + -o, --owner string The owner of the target repository + -T, --token string The authentication token for the API + -p, --project string The name of the target repository + -h, --help help for configure ``` ### SEE ALSO diff --git a/misc/bash_completion/git-bug b/misc/bash_completion/git-bug index 51e30da0..741dcc49 100644 --- a/misc/bash_completion/git-bug +++ b/misc/bash_completion/git-bug @@ -301,6 +301,30 @@ _git-bug_bridge_configure() flags_with_completion=() flags_completion=() + flags+=("--name=") + two_word_flags+=("--name") + two_word_flags+=("-n") + local_nonpersistent_flags+=("--name=") + flags+=("--target=") + two_word_flags+=("--target") + two_word_flags+=("-t") + local_nonpersistent_flags+=("--target=") + flags+=("--url=") + two_word_flags+=("--url") + two_word_flags+=("-u") + local_nonpersistent_flags+=("--url=") + flags+=("--owner=") + two_word_flags+=("--owner") + two_word_flags+=("-o") + local_nonpersistent_flags+=("--owner=") + flags+=("--token=") + two_word_flags+=("--token") + two_word_flags+=("-T") + local_nonpersistent_flags+=("--token=") + flags+=("--project=") + two_word_flags+=("--project") + two_word_flags+=("-p") + local_nonpersistent_flags+=("--project=") must_have_one_flag=() must_have_one_noun=() diff --git a/misc/zsh_completion/git-bug b/misc/zsh_completion/git-bug index 52c242df..c2ed9872 100644 --- a/misc/zsh_completion/git-bug +++ b/misc/zsh_completion/git-bug @@ -8,7 +8,7 @@ case $state in level1) case $words[1] in git-bug) - _arguments '1: :(add bridge commands comment deselect export label ls ls-id ls-label pull push select show status termui title user version webui)' + _arguments '1: :(add bridge commands comment deselect label ls ls-id ls-label pull push select show status termui title user version webui)' ;; *) _arguments '*: :_files' diff --git a/repository/git.go b/repository/git.go index 4d6ca19a..801504f2 100644 --- a/repository/git.go +++ b/repository/git.go @@ -162,6 +162,28 @@ func (repo *GitRepo) GetCoreEditor() (string, error) { return repo.runGitCommand("var", "GIT_EDITOR") } +// GetRemotes returns the configured remotes repositories. +func (repo *GitRepo) GetRemotes() (map[string]string, error) { + stdout, err := repo.runGitCommand("remote", "--verbose") + if err != nil { + return nil, err + } + + lines := strings.Split(stdout, "\n") + remotes := make(map[string]string, len(lines)) + + for _, line := range lines { + elements := strings.Fields(line) + if len(elements) != 3 { + return nil, fmt.Errorf("unexpected output format: %s", line) + } + + remotes[elements[0]] = elements[1] + } + + return remotes, nil +} + // StoreConfig store a single key/value pair in the config of the repo func (repo *GitRepo) StoreConfig(key string, value string) error { _, err := repo.runGitCommand("config", "--replace-all", key, value) diff --git a/repository/mock_repo.go b/repository/mock_repo.go index 14f5e7b5..2dc4868e 100644 --- a/repository/mock_repo.go +++ b/repository/mock_repo.go @@ -59,6 +59,13 @@ func (r *mockRepoForTest) GetCoreEditor() (string, error) { return "vi", nil } +// GetRemotes returns the configured remotes repositories. +func (r *mockRepoForTest) GetRemotes() (map[string]string, error) { + return map[string]string{ + "origin": "git://github.com/MichaelMure/git-bug", + }, nil +} + func (r *mockRepoForTest) StoreConfig(key string, value string) error { r.config[key] = value return nil diff --git a/repository/repo.go b/repository/repo.go index f3c2de6d..44204493 100644 --- a/repository/repo.go +++ b/repository/repo.go @@ -27,6 +27,9 @@ type RepoCommon interface { // GetCoreEditor returns the name of the editor that the user has used to configure git. GetCoreEditor() (string, error) + // GetRemotes returns the configured remotes repositories. + GetRemotes() (map[string]string, error) + // StoreConfig store a single key/value pair in the config of the repo StoreConfig(key string, value string) error |