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