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