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/repository"
23 "github.com/MichaelMure/git-bug/util/colors"
24)
25
26var (
27 ErrBadProjectURL = errors.New("bad project url")
28)
29
30func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
31 if params.Project != "" {
32 fmt.Println("warning: --project is ineffective for a gitlab bridge")
33 }
34 if params.Owner != "" {
35 fmt.Println("warning: --owner is ineffective for a gitlab bridge")
36 }
37
38 conf := make(core.Configuration)
39 var err error
40
41 if (params.CredPrefix != "" || params.TokenRaw != "") && params.URL == "" {
42 return nil, fmt.Errorf("you must provide a project URL to configure this bridge with a token")
43 }
44
45 if params.URL == "" {
46 params.URL = defaultBaseURL
47 }
48
49 var url string
50
51 // get project url
52 switch {
53 case params.URL != "":
54 url = params.URL
55 default:
56 // terminal prompt
57 url, err = promptURL(repo)
58 if err != nil {
59 return nil, errors.Wrap(err, "url prompt")
60 }
61 }
62
63 if !strings.HasPrefix(url, params.BaseURL) {
64 return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, url)
65 }
66
67 user, err := repo.GetUserIdentity()
68 if err != nil {
69 return nil, err
70 }
71
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 if cred.UserId() != user.Id() {
81 return nil, fmt.Errorf("selected credential don't match the user")
82 }
83 case params.TokenRaw != "":
84 cred = auth.NewToken(user.Id(), params.TokenRaw, target)
85 default:
86 cred, err = promptTokenOptions(repo, user.Id())
87 if err != nil {
88 return nil, err
89 }
90 }
91
92 token, ok := cred.(*auth.Token)
93 if !ok {
94 return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
95 }
96
97 // validate project url and get its ID
98 id, err := validateProjectURL(params.BaseURL, url, token)
99 if err != nil {
100 return nil, errors.Wrap(err, "project validation")
101 }
102
103 conf[core.ConfigKeyTarget] = target
104 conf[keyProjectID] = strconv.Itoa(id)
105 conf[keyGitlabBaseUrl] = params.BaseURL
106
107 err = g.ValidateConfig(conf)
108 if err != nil {
109 return nil, err
110 }
111
112 // don't forget to store the now known valid token
113 if !auth.IdExist(repo, cred.ID()) {
114 err = auth.Store(repo, cred)
115 if err != nil {
116 return nil, err
117 }
118 }
119
120 return conf, nil
121}
122
123func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
124 if v, ok := conf[core.ConfigKeyTarget]; !ok {
125 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
126 } else if v != target {
127 return fmt.Errorf("unexpected target name: %v", v)
128 }
129
130 if _, ok := conf[keyProjectID]; !ok {
131 return fmt.Errorf("missing %s key", keyProjectID)
132 }
133
134 return nil
135}
136
137func promptTokenOptions(repo repository.RepoConfig, userId entity.Id) (auth.Credential, error) {
138 for {
139 creds, err := auth.List(repo, auth.WithUserId(userId), auth.WithTarget(target), auth.WithKind(auth.KindToken))
140 if err != nil {
141 return nil, err
142 }
143
144 // if we don't have existing token, fast-track to the token prompt
145 if len(creds) == 0 {
146 value, err := promptToken()
147 if err != nil {
148 return nil, err
149 }
150 return auth.NewToken(userId, value, target), nil
151 }
152
153 fmt.Println()
154 fmt.Println("[1]: enter my token")
155
156 fmt.Println()
157 fmt.Println("Existing tokens for Gitlab:")
158
159 sort.Sort(auth.ById(creds))
160 for i, cred := range creds {
161 token := cred.(*auth.Token)
162 fmt.Printf("[%d]: %s => %s (%s)\n",
163 i+2,
164 colors.Cyan(token.ID().Human()),
165 colors.Red(text.TruncateMax(token.Value, 10)),
166 token.CreateTime().Format(time.RFC822),
167 )
168 }
169
170 fmt.Println()
171 fmt.Print("Select option: ")
172
173 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
174 fmt.Println()
175 if err != nil {
176 return nil, err
177 }
178
179 line = strings.TrimSpace(line)
180 index, err := strconv.Atoi(line)
181 if err != nil || index < 1 || index > len(creds)+1 {
182 fmt.Println("invalid input")
183 continue
184 }
185
186 switch index {
187 case 1:
188 value, err := promptToken()
189 if err != nil {
190 return nil, err
191 }
192 return auth.NewToken(userId, value, target), nil
193 default:
194 return creds[index-2], nil
195 }
196 }
197}
198
199func promptToken() (string, error) {
200 fmt.Println("You can generate a new token by visiting https://gitlab.com/profile/personal_access_tokens.")
201 fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
202 fmt.Println()
203 fmt.Println("'api' access scope: to be able to make api calls")
204 fmt.Println()
205
206 re, err := regexp.Compile(`^[a-zA-Z0-9\-]{20}`)
207 if err != nil {
208 panic("regexp compile:" + err.Error())
209 }
210
211 for {
212 fmt.Print("Enter token: ")
213
214 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
215 if err != nil {
216 return "", err
217 }
218
219 token := strings.TrimSpace(line)
220 if re.MatchString(token) {
221 return token, nil
222 }
223
224 fmt.Println("token format is invalid")
225 }
226}
227
228func promptURL(repo repository.RepoCommon) (string, error) {
229 // remote suggestions
230 remotes, err := repo.GetRemotes()
231 if err != nil {
232 return "", errors.Wrap(err, "getting remotes")
233 }
234
235 validRemotes := getValidGitlabRemoteURLs(remotes)
236 if len(validRemotes) > 0 {
237 for {
238 fmt.Println("\nDetected projects:")
239
240 // print valid remote gitlab urls
241 for i, remote := range validRemotes {
242 fmt.Printf("[%d]: %v\n", i+1, remote)
243 }
244
245 fmt.Printf("\n[0]: Another project\n\n")
246 fmt.Printf("Select option: ")
247
248 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
249 if err != nil {
250 return "", err
251 }
252
253 line = strings.TrimSpace(line)
254
255 index, err := strconv.Atoi(line)
256 if err != nil || index < 0 || index > len(validRemotes) {
257 fmt.Println("invalid input")
258 continue
259 }
260
261 // if user want to enter another project url break this loop
262 if index == 0 {
263 break
264 }
265
266 return validRemotes[index-1], nil
267 }
268 }
269
270 // manually enter gitlab url
271 for {
272 fmt.Print("Gitlab project URL: ")
273
274 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
275 if err != nil {
276 return "", err
277 }
278
279 url := strings.TrimSpace(line)
280 if url == "" {
281 fmt.Println("URL is empty")
282 continue
283 }
284
285 return url, nil
286 }
287}
288
289func getProjectPath(projectUrl string) (string, error) {
290 cleanUrl := strings.TrimSuffix(projectUrl, ".git")
291 cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
292 objectUrl, err := url.Parse(cleanUrl)
293 if err != nil {
294 return "", ErrBadProjectURL
295 }
296
297 return objectUrl.Path[1:], nil
298}
299
300func getValidGitlabRemoteURLs(remotes map[string]string) []string {
301 urls := make([]string, 0, len(remotes))
302 for _, u := range remotes {
303 path, err := getProjectPath(u)
304 if err != nil {
305 continue
306 }
307
308 urls = append(urls, fmt.Sprintf("%s%s", "gitlab.com", path))
309 }
310
311 return urls
312}
313
314func validateProjectURL(baseURL, url string, token *auth.Token) (int, error) {
315 projectPath, err := getProjectPath(url)
316 if err != nil {
317 return 0, err
318 }
319
320 client, err := buildClient(baseURL, token)
321 if err != nil {
322 return 0, err
323 }
324
325 project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
326 if err != nil {
327 return 0, err
328 }
329
330 return project.ID, nil
331}