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