Makefile 🔗
@@ -22,7 +22,7 @@ install:
go install -ldflags "$(LDFLAGS)" .
test:
- go test -bench=. ./...
+ go test -v -bench=. ./...
pack-webui:
npm run --prefix webui build
Michael Muré created
[Breaking] Bridge configuration enhancements
Makefile | 2
bridge/core/bridge.go | 13
bridge/core/interfaces.go | 2
bridge/github/config.go | 406 ++++++++++++++++++++++++++-----
bridge/github/config_test.go | 209 ++++++++++++++++
bridge/github/github.go | 3
bridge/github/import.go | 5
bridge/github/import_test.go | 10
bridge/github/iterator.go | 8
bridge/launchpad/config.go | 79 +++++
bridge/launchpad/config_test.go | 93 +++++++
cache/repo_cache.go | 7
commands/bridge_configure.go | 103 +++++++
doc/man/git-bug-bridge-configure.1 | 87 ++++++
doc/md/git-bug_bridge_configure.md | 60 ++++
misc/bash_completion/git-bug | 24 +
misc/zsh_completion/git-bug | 2
repository/git.go | 22 +
repository/mock_repo.go | 7
repository/repo.go | 3
20 files changed, 1,034 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
@@ -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
}
@@ -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
@@ -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
+ }
+}
@@ -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)
+ })
+ }
+}
@@ -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() {
@@ -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() {
@@ -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)
@@ -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),
},
},
@@ -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
+}
@@ -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)
+ })
+ }
+}
@@ -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()
@@ -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
}
@@ -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
@@ -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
@@ -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=()
@@ -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'
@@ -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)
@@ -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
@@ -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