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"
"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"
"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")
}
conf := make(core.Configuration)
var err error
var baseUrl string
switch {
case params.BaseURL != "":
baseUrl = params.BaseURL
default:
baseUrl, err = promptBaseUrlOptions()
if err != nil {
return nil, errors.Wrap(err, "base url prompt")
}
}
var projectURL string
// get project url
switch {
case params.URL != "":
projectURL = params.URL
default:
// terminal prompt
projectURL, err = promptProjectURL(repo, baseUrl)
if err != nil {
return nil, errors.Wrap(err, "url prompt")
}
}
if !strings.HasPrefix(projectURL, params.BaseURL) {
return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, projectURL)
}
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
case params.TokenRaw != "":
token := auth.NewToken(target, params.TokenRaw)
login, err = getLoginFromToken(baseUrl, token)
if err != nil {
return nil, err
}
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
cred = token
default:
login := params.Login
if login == "" {
// TODO: validate username
login, err = input.Prompt("Gitlab login", "login", input.Required)
if err != nil {
return nil, err
}
}
cred, err = promptTokenOptions(repo, login, baseUrl)
if err != nil {
return nil, err
}
}
token, ok := cred.(*auth.Token)
if !ok {
return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
}
// validate project url and get its ID
id, err := validateProjectURL(baseUrl, projectURL, token)
if err != nil {
return nil, errors.Wrap(err, "project validation")
}
conf[core.ConfigKeyTarget] = target
conf[keyProjectID] = strconv.Itoa(id)
conf[keyGitlabBaseUrl] = baseUrl
err = g.ValidateConfig(conf)
if err != nil {
return nil, err
}
// 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
}
}
return conf, core.FinishConfig(repo, metaKeyGitlabLogin, login)
}
func (g *Gitlab) 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[keyGitlabBaseUrl]; !ok {
return fmt.Errorf("missing %s key", keyGitlabBaseUrl)
}
if _, ok := conf[keyProjectID]; !ok {
return fmt.Errorf("missing %s key", keyProjectID)
}
return nil
}
func promptBaseUrlOptions() (string, error) {
index, err := input.PromptChoice("Gitlab base url", []string{
"https://gitlab.com",
"enter your own base url",
})
if err != nil {
return "", err
}
if index == 0 {
return defaultBaseURL, nil
} else {
return promptBaseUrl()
}
}
func promptBaseUrl() (string, error) {
validator := func(name string, value string) (string, error) {
u, err := url.Parse(value)
if err != nil {
return err.Error(), nil
}
if u.Scheme == "" {
return "missing scheme", nil
}
if u.Host == "" {
return "missing host", nil
}
return "", nil
}
return input.Prompt("Base url", "url", input.Required, validator)
}
func promptTokenOptions(repo repository.RepoConfig, 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
}
}
}
func promptToken(baseUrl string) (*auth.Token, error) {
fmt.Printf("You can generate a new token by visiting %s.\n", path.Join(baseUrl, "profile/personal_access_tokens"))
fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
fmt.Println()
fmt.Println("'api' access scope: to be able to make api calls")
fmt.Println()
re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}$`)
if err != nil {
panic("regexp compile:" + err.Error())
}
var login string
validator := func(name string, value string) (complaint string, err error) {
if !re.MatchString(value) {
return "token has incorrect format", nil
}
login, err = getLoginFromToken(baseUrl, auth.NewToken(target, value))
if err != nil {
return fmt.Sprintf("token is invalid: %v", err), nil
}
return "", nil
}
rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
if err != nil {
return nil, err
}
token := auth.NewToken(target, rawToken)
token.SetMetadata(auth.MetaKeyLogin, login)
token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
return token, nil
}
func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) {
// remote suggestions
remotes, err := repo.GetRemotes()
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
}
}
// 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
}
}
func getProjectPath(baseUrl, projectUrl string) (string, error) {
cleanUrl := strings.TrimSuffix(projectUrl, ".git")
cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
objectUrl, err := url.Parse(cleanUrl)
if err != nil {
return "", ErrBadProjectURL
}
objectBaseUrl, err := url.Parse(baseUrl)
if err != nil {
return "", ErrBadProjectURL
}
if objectUrl.Hostname() != objectBaseUrl.Hostname() {
return "", fmt.Errorf("base url and project url hostnames doesn't match")
}
return objectUrl.Path[1:], nil
}
func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string {
urls := make([]string, 0, len(remotes))
for _, u := range remotes {
path, err := getProjectPath(baseUrl, u)
if err != nil {
continue
}
urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path))
}
return urls
}
func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
projectPath, err := getProjectPath(baseUrl, url)
if err != nil {
return 0, err
}
client, err := buildClient(baseUrl, token)
if err != nil {
return 0, err
}
project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
if err != nil {
return 0, errors.Wrap(err, "wrong token scope ou non-existent project")
}
return project.ID, nil
}
func getLoginFromToken(baseUrl string, token *auth.Token) (string, error) {
client, err := buildClient(baseUrl, token)
if err != nil {
return "", err
}
user, _, err := client.Users.CurrentUser()
if err != nil {
return "", err
}
if user.Username == "" {
return "", fmt.Errorf("gitlab say username is empty")
}
return user.Username, nil
}