1package gitlab
2
3import (
4 "bufio"
5 "fmt"
6 "net/url"
7 "os"
8 "path"
9 "regexp"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14
15 text "github.com/MichaelMure/go-term-text"
16 "github.com/pkg/errors"
17 "github.com/xanzy/go-gitlab"
18
19 "github.com/MichaelMure/git-bug/bridge/core"
20 "github.com/MichaelMure/git-bug/bridge/core/auth"
21 "github.com/MichaelMure/git-bug/cache"
22 "github.com/MichaelMure/git-bug/input"
23 "github.com/MichaelMure/git-bug/repository"
24 "github.com/MichaelMure/git-bug/util/colors"
25)
26
27var (
28 ErrBadProjectURL = errors.New("bad project url")
29)
30
31func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
32 if params.Project != "" {
33 fmt.Println("warning: --project is ineffective for a gitlab bridge")
34 }
35 if params.Owner != "" {
36 fmt.Println("warning: --owner is ineffective for a gitlab bridge")
37 }
38
39 conf := make(core.Configuration)
40 var err error
41 var baseUrl string
42
43 switch {
44 case params.BaseURL != "":
45 baseUrl = params.BaseURL
46 default:
47 baseUrl, err = promptBaseUrlOptions()
48 if err != nil {
49 return nil, errors.Wrap(err, "base url prompt")
50 }
51 }
52
53 var projectURL string
54
55 // get project url
56 switch {
57 case params.URL != "":
58 projectURL = params.URL
59 default:
60 // terminal prompt
61 projectURL, err = promptProjectURL(repo, baseUrl)
62 if err != nil {
63 return nil, errors.Wrap(err, "url prompt")
64 }
65 }
66
67 if !strings.HasPrefix(projectURL, params.BaseURL) {
68 return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, projectURL)
69 }
70
71 var login string
72 var cred auth.Credential
73
74 switch {
75 case params.CredPrefix != "":
76 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
77 if err != nil {
78 return nil, err
79 }
80 l, ok := cred.GetMetadata(auth.MetaKeyLogin)
81 if !ok {
82 return nil, fmt.Errorf("credential doesn't have a login")
83 }
84 login = l
85 case params.TokenRaw != "":
86 token := auth.NewToken(target, params.TokenRaw)
87 login, err = getLoginFromToken(baseUrl, token)
88 if err != nil {
89 return nil, err
90 }
91 token.SetMetadata(auth.MetaKeyLogin, login)
92 token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
93 cred = token
94 default:
95 login := params.Login
96 if login == "" {
97 // TODO: validate username
98 login, err = input.Prompt("Gitlab login", "login", input.Required)
99 if err != nil {
100 return nil, err
101 }
102 }
103 cred, err = promptTokenOptions(repo, login, baseUrl)
104 if err != nil {
105 return nil, err
106 }
107 }
108
109 token, ok := cred.(*auth.Token)
110 if !ok {
111 return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
112 }
113
114 // validate project url and get its ID
115 id, err := validateProjectURL(baseUrl, projectURL, token)
116 if err != nil {
117 return nil, errors.Wrap(err, "project validation")
118 }
119
120 conf[core.ConfigKeyTarget] = target
121 conf[keyProjectID] = strconv.Itoa(id)
122 conf[keyGitlabBaseUrl] = baseUrl
123
124 err = g.ValidateConfig(conf)
125 if err != nil {
126 return nil, err
127 }
128
129 // don't forget to store the now known valid token
130 if !auth.IdExist(repo, cred.ID()) {
131 err = auth.Store(repo, cred)
132 if err != nil {
133 return nil, err
134 }
135 }
136
137 return conf, core.FinishConfig(repo, metaKeyGitlabLogin, login)
138}
139
140func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
141 if v, ok := conf[core.ConfigKeyTarget]; !ok {
142 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
143 } else if v != target {
144 return fmt.Errorf("unexpected target name: %v", v)
145 }
146 if _, ok := conf[keyGitlabBaseUrl]; !ok {
147 return fmt.Errorf("missing %s key", keyGitlabBaseUrl)
148 }
149 if _, ok := conf[keyProjectID]; !ok {
150 return fmt.Errorf("missing %s key", keyProjectID)
151 }
152
153 return nil
154}
155
156func promptBaseUrlOptions() (string, error) {
157 index, err := input.PromptChoice("Gitlab base url", []string{
158 "https://gitlab.com",
159 "enter your own base url",
160 })
161
162 if err != nil {
163 return "", err
164 }
165
166 if index == 0 {
167 return defaultBaseURL, nil
168 } else {
169 return promptBaseUrl()
170 }
171}
172
173func promptBaseUrl() (string, error) {
174 validator := func(name string, value string) (string, error) {
175 u, err := url.Parse(value)
176 if err != nil {
177 return err.Error(), nil
178 }
179 if u.Scheme == "" {
180 return "missing scheme", nil
181 }
182 if u.Host == "" {
183 return "missing host", nil
184 }
185 return "", nil
186 }
187
188 return input.Prompt("Base url", "url", input.Required, validator)
189}
190
191func promptTokenOptions(repo repository.RepoConfig, login, baseUrl string) (auth.Credential, error) {
192 for {
193 creds, err := auth.List(repo,
194 auth.WithTarget(target),
195 auth.WithKind(auth.KindToken),
196 auth.WithMeta(auth.MetaKeyLogin, login),
197 auth.WithMeta(auth.MetaKeyBaseURL, baseUrl),
198 )
199 if err != nil {
200 return nil, err
201 }
202
203 // if we don't have existing token, fast-track to the token prompt
204 if len(creds) == 0 {
205 return promptToken(baseUrl)
206 }
207
208 fmt.Println()
209 fmt.Println("[1]: enter my token")
210
211 fmt.Println()
212 fmt.Println("Existing tokens for Gitlab:")
213
214 sort.Sort(auth.ById(creds))
215 for i, cred := range creds {
216 token := cred.(*auth.Token)
217 fmt.Printf("[%d]: %s => %s (%s)\n",
218 i+2,
219 colors.Cyan(token.ID().Human()),
220 colors.Red(text.TruncateMax(token.Value, 10)),
221 token.CreateTime().Format(time.RFC822),
222 )
223 }
224
225 fmt.Println()
226 fmt.Print("Select option: ")
227
228 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
229 fmt.Println()
230 if err != nil {
231 return nil, err
232 }
233
234 line = strings.TrimSpace(line)
235 index, err := strconv.Atoi(line)
236 if err != nil || index < 1 || index > len(creds)+1 {
237 fmt.Println("invalid input")
238 continue
239 }
240
241 switch index {
242 case 1:
243 return promptToken(baseUrl)
244 default:
245 return creds[index-2], nil
246 }
247 }
248}
249
250func promptToken(baseUrl string) (*auth.Token, error) {
251 fmt.Printf("You can generate a new token by visiting %s.\n", path.Join(baseUrl, "profile/personal_access_tokens"))
252 fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
253 fmt.Println()
254 fmt.Println("'api' access scope: to be able to make api calls")
255 fmt.Println()
256
257 re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}$`)
258 if err != nil {
259 panic("regexp compile:" + err.Error())
260 }
261
262 var login string
263
264 validator := func(name string, value string) (complaint string, err error) {
265 if !re.MatchString(value) {
266 return "token has incorrect format", nil
267 }
268 login, err = getLoginFromToken(baseUrl, auth.NewToken(target, value))
269 if err != nil {
270 return fmt.Sprintf("token is invalid: %v", err), nil
271 }
272 return "", nil
273 }
274
275 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
276 if err != nil {
277 return nil, err
278 }
279
280 token := auth.NewToken(target, rawToken)
281 token.SetMetadata(auth.MetaKeyLogin, login)
282 token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
283
284 return token, nil
285}
286
287func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) {
288 // remote suggestions
289 remotes, err := repo.GetRemotes()
290 if err != nil {
291 return "", errors.Wrap(err, "getting remotes")
292 }
293
294 validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes)
295 if len(validRemotes) > 0 {
296 for {
297 fmt.Println("\nDetected projects:")
298
299 // print valid remote gitlab urls
300 for i, remote := range validRemotes {
301 fmt.Printf("[%d]: %v\n", i+1, remote)
302 }
303
304 fmt.Printf("\n[0]: Another project\n\n")
305 fmt.Printf("Select option: ")
306
307 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
308 if err != nil {
309 return "", err
310 }
311
312 line = strings.TrimSpace(line)
313
314 index, err := strconv.Atoi(line)
315 if err != nil || index < 0 || index > len(validRemotes) {
316 fmt.Println("invalid input")
317 continue
318 }
319
320 // if user want to enter another project url break this loop
321 if index == 0 {
322 break
323 }
324
325 return validRemotes[index-1], nil
326 }
327 }
328
329 // manually enter gitlab url
330 for {
331 fmt.Print("Gitlab project URL: ")
332
333 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
334 if err != nil {
335 return "", err
336 }
337
338 projectURL := strings.TrimSpace(line)
339 if projectURL == "" {
340 fmt.Println("URL is empty")
341 continue
342 }
343
344 return projectURL, nil
345 }
346}
347
348func getProjectPath(baseUrl, projectUrl string) (string, error) {
349 cleanUrl := strings.TrimSuffix(projectUrl, ".git")
350 cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
351 objectUrl, err := url.Parse(cleanUrl)
352 if err != nil {
353 return "", ErrBadProjectURL
354 }
355
356 objectBaseUrl, err := url.Parse(baseUrl)
357 if err != nil {
358 return "", ErrBadProjectURL
359 }
360
361 if objectUrl.Hostname() != objectBaseUrl.Hostname() {
362 return "", fmt.Errorf("base url and project url hostnames doesn't match")
363 }
364 return objectUrl.Path[1:], nil
365}
366
367func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string {
368 urls := make([]string, 0, len(remotes))
369 for _, u := range remotes {
370 path, err := getProjectPath(baseUrl, u)
371 if err != nil {
372 continue
373 }
374
375 urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path))
376 }
377
378 return urls
379}
380
381func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
382 projectPath, err := getProjectPath(baseUrl, url)
383 if err != nil {
384 return 0, err
385 }
386
387 client, err := buildClient(baseUrl, token)
388 if err != nil {
389 return 0, err
390 }
391
392 project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
393 if err != nil {
394 return 0, errors.Wrap(err, "wrong token scope ou non-existent project")
395 }
396
397 return project.ID, nil
398}
399
400func getLoginFromToken(baseUrl string, token *auth.Token) (string, error) {
401 client, err := buildClient(baseUrl, token)
402 if err != nil {
403 return "", err
404 }
405
406 user, _, err := client.Users.CurrentUser()
407 if err != nil {
408 return "", err
409 }
410 if user.Username == "" {
411 return "", fmt.Errorf("gitlab say username is empty")
412 }
413
414 return user.Username, nil
415}