1package gitlab
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "strconv"
8 "time"
9
10 "github.com/pkg/errors"
11 "github.com/xanzy/go-gitlab"
12
13 "github.com/MichaelMure/git-bug/bridge/core"
14 "github.com/MichaelMure/git-bug/bridge/core/auth"
15 "github.com/MichaelMure/git-bug/bug"
16 "github.com/MichaelMure/git-bug/cache"
17 "github.com/MichaelMure/git-bug/entity"
18 "github.com/MichaelMure/git-bug/identity"
19)
20
21var (
22 ErrMissingIdentityToken = errors.New("missing identity token")
23)
24
25// gitlabExporter implement the Exporter interface
26type gitlabExporter struct {
27 conf core.Configuration
28
29 // cache identities clients
30 identityClient map[entity.Id]*gitlab.Client
31
32 // gitlab repository ID
33 repositoryID string
34
35 // cache identifiers used to speed up exporting operations
36 // cleared for each bug
37 cachedOperationIDs map[string]string
38}
39
40// Init .
41func (ge *gitlabExporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
42 ge.conf = conf
43 ge.identityClient = make(map[entity.Id]*gitlab.Client)
44 ge.cachedOperationIDs = make(map[string]string)
45
46 // get repository node id
47 ge.repositoryID = ge.conf[confKeyProjectID]
48
49 // preload all clients
50 err := ge.cacheAllClient(repo, ge.conf[confKeyGitlabBaseUrl])
51 if err != nil {
52 return err
53 }
54
55 return nil
56}
57
58func (ge *gitlabExporter) cacheAllClient(repo *cache.RepoCache, baseURL string) error {
59 creds, err := auth.List(repo,
60 auth.WithTarget(target),
61 auth.WithKind(auth.KindToken),
62 auth.WithMeta(auth.MetaKeyBaseURL, baseURL),
63 )
64 if err != nil {
65 return err
66 }
67
68 for _, cred := range creds {
69 login, ok := cred.GetMetadata(auth.MetaKeyLogin)
70 if !ok {
71 _, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with a Gitlab login\n", cred.ID().Human())
72 continue
73 }
74
75 user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabLogin, login)
76 if err == identity.ErrIdentityNotExist {
77 continue
78 }
79 if err != nil {
80 return nil
81 }
82
83 if _, ok := ge.identityClient[user.Id()]; !ok {
84 client, err := buildClient(ge.conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token))
85 if err != nil {
86 return err
87 }
88 ge.identityClient[user.Id()] = client
89 }
90 }
91
92 return nil
93}
94
95// getIdentityClient return a gitlab v4 API client configured with the access token of the given identity.
96func (ge *gitlabExporter) getIdentityClient(userId entity.Id) (*gitlab.Client, error) {
97 client, ok := ge.identityClient[userId]
98 if ok {
99 return client, nil
100 }
101
102 return nil, ErrMissingIdentityToken
103}
104
105// ExportAll export all event made by the current user to Gitlab
106func (ge *gitlabExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
107 out := make(chan core.ExportResult)
108
109 go func() {
110 defer close(out)
111
112 allIdentitiesIds := make([]entity.Id, 0, len(ge.identityClient))
113 for id := range ge.identityClient {
114 allIdentitiesIds = append(allIdentitiesIds, id)
115 }
116
117 allBugsIds := repo.AllBugsIds()
118
119 for _, id := range allBugsIds {
120 select {
121 case <-ctx.Done():
122 return
123 default:
124 b, err := repo.ResolveBug(id)
125 if err != nil {
126 out <- core.NewExportError(err, id)
127 return
128 }
129
130 snapshot := b.Snapshot()
131
132 // ignore issues created before since date
133 // TODO: compare the Lamport time instead of using the unix time
134 if snapshot.CreatedAt.Before(since) {
135 out <- core.NewExportNothing(b.Id(), "bug created before the since date")
136 continue
137 }
138
139 if snapshot.HasAnyActor(allIdentitiesIds...) {
140 // try to export the bug and it associated events
141 ge.exportBug(ctx, b, out)
142 }
143 }
144 }
145 }()
146
147 return out, nil
148}
149
150// exportBug publish bugs and related events
151func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, out chan<- core.ExportResult) {
152 snapshot := b.Snapshot()
153
154 var bugUpdated bool
155 var err error
156 var bugGitlabID int
157 var bugGitlabIDString string
158 var GitlabBaseUrl string
159 var bugCreationId string
160
161 // Special case:
162 // if a user try to export a bug that is not already exported to Gitlab (or imported
163 // from Gitlab) and we do not have the token of the bug author, there is nothing we can do.
164
165 // skip bug if origin is not allowed
166 origin, ok := snapshot.GetCreateMetadata(core.MetaKeyOrigin)
167 if ok && origin != target {
168 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
169 return
170 }
171
172 // first operation is always createOp
173 createOp := snapshot.Operations[0].(*bug.CreateOperation)
174 author := snapshot.Author
175
176 // get gitlab bug ID
177 gitlabID, ok := snapshot.GetCreateMetadata(metaKeyGitlabId)
178 if ok {
179 gitlabBaseUrl, ok := snapshot.GetCreateMetadata(metaKeyGitlabBaseUrl)
180 if ok && gitlabBaseUrl != ge.conf[confKeyGitlabBaseUrl] {
181 out <- core.NewExportNothing(b.Id(), "skipping issue imported from another Gitlab instance")
182 return
183 }
184
185 projectID, ok := snapshot.GetCreateMetadata(metaKeyGitlabProject)
186 if !ok {
187 err := fmt.Errorf("expected to find gitlab project id")
188 out <- core.NewExportError(err, b.Id())
189 return
190 }
191
192 if projectID != ge.conf[confKeyProjectID] {
193 out <- core.NewExportNothing(b.Id(), "skipping issue imported from another repository")
194 return
195 }
196
197 // will be used to mark operation related to a bug as exported
198 bugGitlabIDString = gitlabID
199 bugGitlabID, err = strconv.Atoi(bugGitlabIDString)
200 if err != nil {
201 out <- core.NewExportError(fmt.Errorf("unexpected gitlab id format: %s", bugGitlabIDString), b.Id())
202 return
203 }
204
205 } else {
206 // check that we have a token for operation author
207 client, err := ge.getIdentityClient(author.Id())
208 if err != nil {
209 // if bug is still not exported and we do not have the author stop the execution
210 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
211 return
212 }
213
214 // create bug
215 _, id, url, err := createGitlabIssue(ctx, client, ge.repositoryID, createOp.Title, createOp.Message)
216 if err != nil {
217 err := errors.Wrap(err, "exporting gitlab issue")
218 out <- core.NewExportError(err, b.Id())
219 return
220 }
221
222 idString := strconv.Itoa(id)
223 out <- core.NewExportBug(b.Id())
224
225 _, err = b.SetMetadata(
226 createOp.Id(),
227 map[string]string{
228 metaKeyGitlabId: idString,
229 metaKeyGitlabUrl: url,
230 metaKeyGitlabProject: ge.repositoryID,
231 metaKeyGitlabBaseUrl: GitlabBaseUrl,
232 },
233 )
234 if err != nil {
235 err := errors.Wrap(err, "marking operation as exported")
236 out <- core.NewExportError(err, b.Id())
237 return
238 }
239
240 // commit operation to avoid creating multiple issues with multiple pushes
241 if err := b.CommitAsNeeded(); err != nil {
242 err := errors.Wrap(err, "bug commit")
243 out <- core.NewExportError(err, b.Id())
244 return
245 }
246
247 // cache bug gitlab ID and URL
248 bugGitlabID = id
249 bugGitlabIDString = idString
250 }
251
252 bugCreationId = createOp.Id().String()
253 // cache operation gitlab id
254 ge.cachedOperationIDs[bugCreationId] = bugGitlabIDString
255
256 labelSet := make(map[string]struct{})
257 for _, op := range snapshot.Operations[1:] {
258 // ignore SetMetadata operations
259 if _, ok := op.(*bug.SetMetadataOperation); ok {
260 continue
261 }
262
263 // ignore operations already existing in gitlab (due to import or export)
264 // cache the ID of already exported or imported issues and events from Gitlab
265 if id, ok := op.GetMetadata(metaKeyGitlabId); ok {
266 ge.cachedOperationIDs[op.Id().String()] = id
267 continue
268 }
269
270 opAuthor := op.GetAuthor()
271 client, err := ge.getIdentityClient(opAuthor.Id())
272 if err != nil {
273 continue
274 }
275
276 var id int
277 var idString, url string
278 switch op := op.(type) {
279 case *bug.AddCommentOperation:
280
281 // send operation to gitlab
282 id, err = addCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, op.Message)
283 if err != nil {
284 err := errors.Wrap(err, "adding comment")
285 out <- core.NewExportError(err, b.Id())
286 return
287 }
288
289 out <- core.NewExportComment(op.Id())
290
291 idString = strconv.Itoa(id)
292 // cache comment id
293 ge.cachedOperationIDs[op.Id().String()] = idString
294
295 case *bug.EditCommentOperation:
296 targetId := op.Target.String()
297
298 // Since gitlab doesn't consider the issue body as a comment
299 if targetId == bugCreationId {
300
301 // case bug creation operation: we need to edit the Gitlab issue
302 if err := updateGitlabIssueBody(ctx, client, ge.repositoryID, bugGitlabID, op.Message); err != nil {
303 err := errors.Wrap(err, "editing issue")
304 out <- core.NewExportError(err, b.Id())
305 return
306 }
307
308 out <- core.NewExportCommentEdition(op.Id())
309 id = bugGitlabID
310
311 } else {
312
313 // case comment edition operation: we need to edit the Gitlab comment
314 commentID, ok := ge.cachedOperationIDs[targetId]
315 if !ok {
316 out <- core.NewExportError(fmt.Errorf("unexpected error: comment id not found"), op.Target)
317 return
318 }
319
320 commentIDint, err := strconv.Atoi(commentID)
321 if err != nil {
322 out <- core.NewExportError(fmt.Errorf("unexpected comment id format"), op.Target)
323 return
324 }
325
326 if err := editCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, commentIDint, op.Message); err != nil {
327 err := errors.Wrap(err, "editing comment")
328 out <- core.NewExportError(err, b.Id())
329 return
330 }
331
332 out <- core.NewExportCommentEdition(op.Id())
333 id = commentIDint
334 }
335
336 case *bug.SetStatusOperation:
337 if err := updateGitlabIssueStatus(ctx, client, ge.repositoryID, bugGitlabID, op.Status); err != nil {
338 err := errors.Wrap(err, "editing status")
339 out <- core.NewExportError(err, b.Id())
340 return
341 }
342
343 out <- core.NewExportStatusChange(op.Id())
344 id = bugGitlabID
345
346 case *bug.SetTitleOperation:
347 if err := updateGitlabIssueTitle(ctx, client, ge.repositoryID, bugGitlabID, op.Title); err != nil {
348 err := errors.Wrap(err, "editing title")
349 out <- core.NewExportError(err, b.Id())
350 return
351 }
352
353 out <- core.NewExportTitleEdition(op.Id())
354 id = bugGitlabID
355
356 case *bug.LabelChangeOperation:
357 // we need to set the actual list of labels at each label change operation
358 // because gitlab update issue requests need directly the latest list of the verison
359
360 for _, label := range op.Added {
361 labelSet[label.String()] = struct{}{}
362 }
363
364 for _, label := range op.Removed {
365 delete(labelSet, label.String())
366 }
367
368 labels := make([]string, 0, len(labelSet))
369 for key := range labelSet {
370 labels = append(labels, key)
371 }
372
373 if err := updateGitlabIssueLabels(ctx, client, ge.repositoryID, bugGitlabID, labels); err != nil {
374 err := errors.Wrap(err, "updating labels")
375 out <- core.NewExportError(err, b.Id())
376 return
377 }
378
379 out <- core.NewExportLabelChange(op.Id())
380 id = bugGitlabID
381 default:
382 panic("unhandled operation type case")
383 }
384
385 idString = strconv.Itoa(id)
386 // mark operation as exported
387 if err := markOperationAsExported(b, op.Id(), idString, url); err != nil {
388 err := errors.Wrap(err, "marking operation as exported")
389 out <- core.NewExportError(err, b.Id())
390 return
391 }
392
393 // commit at each operation export to avoid exporting same events multiple times
394 if err := b.CommitAsNeeded(); err != nil {
395 err := errors.Wrap(err, "bug commit")
396 out <- core.NewExportError(err, b.Id())
397 return
398 }
399
400 bugUpdated = true
401 }
402
403 if !bugUpdated {
404 out <- core.NewExportNothing(b.Id(), "nothing has been exported")
405 }
406}
407
408func markOperationAsExported(b *cache.BugCache, target entity.Id, gitlabID, gitlabURL string) error {
409 _, err := b.SetMetadata(
410 target,
411 map[string]string{
412 metaKeyGitlabId: gitlabID,
413 metaKeyGitlabUrl: gitlabURL,
414 },
415 )
416
417 return err
418}
419
420// create a gitlab. issue and return it ID
421func createGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID, title, body string) (int, int, string, error) {
422 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
423 defer cancel()
424 issue, _, err := gc.Issues.CreateIssue(
425 repositoryID,
426 &gitlab.CreateIssueOptions{
427 Title: &title,
428 Description: &body,
429 },
430 gitlab.WithContext(ctx),
431 )
432 if err != nil {
433 return 0, 0, "", err
434 }
435
436 return issue.ID, issue.IID, issue.WebURL, nil
437}
438
439// add a comment to an issue and return it ID
440func addCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) (int, error) {
441 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
442 defer cancel()
443 note, _, err := gc.Notes.CreateIssueNote(
444 repositoryID, issueID,
445 &gitlab.CreateIssueNoteOptions{
446 Body: &body,
447 },
448 gitlab.WithContext(ctx),
449 )
450 if err != nil {
451 return 0, err
452 }
453
454 return note.ID, nil
455}
456
457func editCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID, noteID int, body string) error {
458 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
459 defer cancel()
460 _, _, err := gc.Notes.UpdateIssueNote(
461 repositoryID, issueID, noteID,
462 &gitlab.UpdateIssueNoteOptions{
463 Body: &body,
464 },
465 gitlab.WithContext(ctx),
466 )
467
468 return err
469}
470
471func updateGitlabIssueStatus(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, status bug.Status) error {
472 var state string
473
474 switch status {
475 case bug.OpenStatus:
476 state = "reopen"
477 case bug.ClosedStatus:
478 state = "close"
479 default:
480 panic("unknown bug state")
481 }
482
483 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
484 defer cancel()
485 _, _, err := gc.Issues.UpdateIssue(
486 repositoryID, issueID,
487 &gitlab.UpdateIssueOptions{
488 StateEvent: &state,
489 },
490 gitlab.WithContext(ctx),
491 )
492
493 return err
494}
495
496func updateGitlabIssueBody(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) error {
497 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
498 defer cancel()
499 _, _, err := gc.Issues.UpdateIssue(
500 repositoryID, issueID,
501 &gitlab.UpdateIssueOptions{
502 Description: &body,
503 },
504 gitlab.WithContext(ctx),
505 )
506
507 return err
508}
509
510func updateGitlabIssueTitle(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, title string) error {
511 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
512 defer cancel()
513 _, _, err := gc.Issues.UpdateIssue(
514 repositoryID, issueID,
515 &gitlab.UpdateIssueOptions{
516 Title: &title,
517 },
518 gitlab.WithContext(ctx),
519 )
520
521 return err
522}
523
524// update gitlab. issue labels
525func updateGitlabIssueLabels(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, labels []string) error {
526 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
527 defer cancel()
528 gitlabLabels := gitlab.Labels(labels)
529 _, _, err := gc.Issues.UpdateIssue(
530 repositoryID, issueID,
531 &gitlab.UpdateIssueOptions{
532 Labels: &gitlabLabels,
533 },
534 gitlab.WithContext(ctx),
535 )
536
537 return err
538}