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