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