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