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