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 var allIdentitiesIds []entity.Id
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 } else {
121 out <- core.NewExportNothing(id, "not an actor")
122 }
123 }
124 }
125 }()
126
127 return out, nil
128}
129
130// exportBug publish bugs and related events
131func (ge *gitlabExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) {
132 snapshot := b.Snapshot()
133
134 var err error
135 var bugGitlabID int
136 var bugGitlabIDString string
137 var bugCreationId string
138
139 // Special case:
140 // if a user try to export a bug that is not already exported to Gitlab (or imported
141 // from Gitlab) and we do not have the token of the bug author, there is nothing we can do.
142
143 // skip bug if origin is not allowed
144 origin, ok := snapshot.GetCreateMetadata(core.KeyOrigin)
145 if ok && origin != target {
146 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
147 return
148 }
149
150 // first operation is always createOp
151 createOp := snapshot.Operations[0].(*bug.CreateOperation)
152 author := snapshot.Author
153
154 // get gitlab bug ID
155 gitlabID, ok := snapshot.GetCreateMetadata(keyGitlabId)
156 if ok {
157 _, ok := snapshot.GetCreateMetadata(keyGitlabUrl)
158 if !ok {
159 // if we find gitlab ID, gitlab URL must be found too
160 err := fmt.Errorf("expected to find gitlab issue URL")
161 out <- core.NewExportError(err, b.Id())
162 return
163 }
164
165 out <- core.NewExportNothing(b.Id(), "bug already exported")
166
167 // will be used to mark operation related to a bug as exported
168 bugGitlabIDString = gitlabID
169 bugGitlabID, err = strconv.Atoi(bugGitlabIDString)
170 if err != nil {
171 panic("unexpected gitlab id format")
172 }
173
174 } else {
175 // check that we have a token for operation author
176 client, err := ge.getIdentityClient(author.Id())
177 if err != nil {
178 // if bug is still not exported and we do not have the author stop the execution
179 out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
180 return
181 }
182
183 // create bug
184 _, id, url, err := createGitlabIssue(ctx, client, ge.repositoryID, createOp.Title, createOp.Message)
185 if err != nil {
186 err := errors.Wrap(err, "exporting gitlab issue")
187 out <- core.NewExportError(err, b.Id())
188 return
189 }
190
191 idString := strconv.Itoa(id)
192 out <- core.NewExportBug(b.Id())
193
194 // mark bug creation operation as exported
195 if err := markOperationAsExported(b, createOp.Id(), idString, url); err != nil {
196 err := errors.Wrap(err, "marking operation as exported")
197 out <- core.NewExportError(err, b.Id())
198 return
199 }
200
201 // commit operation to avoid creating multiple issues with multiple pushes
202 if err := b.CommitAsNeeded(); err != nil {
203 err := errors.Wrap(err, "bug commit")
204 out <- core.NewExportError(err, b.Id())
205 return
206 }
207
208 // cache bug gitlab ID and URL
209 bugGitlabID = id
210 bugGitlabIDString = idString
211 }
212
213 bugCreationId = createOp.Id().String()
214 // cache operation gitlab id
215 ge.cachedOperationIDs[bugCreationId] = bugGitlabIDString
216
217 var actualLabels []string
218 for _, op := range snapshot.Operations[1:] {
219 // ignore SetMetadata operations
220 if _, ok := op.(*bug.SetMetadataOperation); ok {
221 continue
222 }
223
224 // ignore operations already existing in gitlab (due to import or export)
225 // cache the ID of already exported or imported issues and events from Gitlab
226 if id, ok := op.GetMetadata(keyGitlabId); ok {
227 ge.cachedOperationIDs[op.Id().String()] = id
228 out <- core.NewExportNothing(op.Id(), "already exported operation")
229 continue
230 }
231
232 opAuthor := op.GetAuthor()
233 client, err := ge.getIdentityClient(opAuthor.Id())
234 if err != nil {
235 out <- core.NewExportNothing(op.Id(), "missing operation author token")
236 continue
237 }
238
239 var id int
240 var idString, url string
241 switch op.(type) {
242 case *bug.AddCommentOperation:
243 opr := op.(*bug.AddCommentOperation)
244
245 // send operation to gitlab
246 id, err = addCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, opr.Message)
247 if err != nil {
248 err := errors.Wrap(err, "adding comment")
249 out <- core.NewExportError(err, b.Id())
250 return
251 }
252
253 out <- core.NewExportComment(op.Id())
254
255 idString = strconv.Itoa(id)
256 // cache comment id
257 ge.cachedOperationIDs[op.Id().String()] = idString
258
259 case *bug.EditCommentOperation:
260 opr := op.(*bug.EditCommentOperation)
261 targetId := opr.Target.String()
262
263 // Since gitlab doesn't consider the issue body as a comment
264 if targetId == bugCreationId {
265
266 // case bug creation operation: we need to edit the Gitlab issue
267 if err := updateGitlabIssueBody(ctx, client, ge.repositoryID, bugGitlabID, opr.Message); err != nil {
268 err := errors.Wrap(err, "editing issue")
269 out <- core.NewExportError(err, b.Id())
270 return
271 }
272
273 out <- core.NewExportCommentEdition(op.Id())
274 id = bugGitlabID
275
276 } else {
277
278 // case comment edition operation: we need to edit the Gitlab comment
279 commentID, ok := ge.cachedOperationIDs[targetId]
280 if !ok {
281 panic("unexpected error: comment id not found")
282 }
283
284 commentIDint, err := strconv.Atoi(commentID)
285 if err != nil {
286 panic("unexpected comment id format")
287 }
288
289 if err := editCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, commentIDint, opr.Message); err != nil {
290 err := errors.Wrap(err, "editing comment")
291 out <- core.NewExportError(err, b.Id())
292 return
293 }
294
295 out <- core.NewExportCommentEdition(op.Id())
296 id = commentIDint
297 }
298
299 case *bug.SetStatusOperation:
300 opr := op.(*bug.SetStatusOperation)
301 if err := updateGitlabIssueStatus(ctx, client, ge.repositoryID, bugGitlabID, opr.Status); err != nil {
302 err := errors.Wrap(err, "editing status")
303 out <- core.NewExportError(err, b.Id())
304 return
305 }
306
307 out <- core.NewExportStatusChange(op.Id())
308 id = bugGitlabID
309
310 case *bug.SetTitleOperation:
311 opr := op.(*bug.SetTitleOperation)
312 if err := updateGitlabIssueTitle(ctx, client, ge.repositoryID, bugGitlabID, opr.Title); err != nil {
313 err := errors.Wrap(err, "editing title")
314 out <- core.NewExportError(err, b.Id())
315 return
316 }
317
318 out <- core.NewExportTitleEdition(op.Id())
319 id = bugGitlabID
320
321 case *bug.LabelChangeOperation:
322 opr := op.(*bug.LabelChangeOperation)
323 // we need to set the actual list of labels at each label change operation
324 // because gitlab update issue requests need directly the latest list of the verison
325
326 if len(opr.Added) != 0 {
327 for _, label := range opr.Added {
328 actualLabels = append(actualLabels, string(label))
329 }
330 }
331
332 if len(opr.Removed) != 0 {
333 var newActualLabels []string
334 for _, label := range actualLabels {
335 for _, l := range opr.Removed {
336 if label == string(l) {
337 continue
338 }
339
340 newActualLabels = append(newActualLabels, label)
341 }
342 }
343 actualLabels = newActualLabels
344 }
345
346 if err := updateGitlabIssueLabels(ctx, client, ge.repositoryID, bugGitlabID, actualLabels); err != nil {
347 err := errors.Wrap(err, "updating labels")
348 out <- core.NewExportError(err, b.Id())
349 return
350 }
351
352 out <- core.NewExportLabelChange(op.Id())
353 id = bugGitlabID
354 default:
355 panic("unhandled operation type case")
356 }
357
358 idString = strconv.Itoa(id)
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
387// create a gitlab. issue and return it ID
388func createGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID, title, body string) (int, int, string, error) {
389 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
390 defer cancel()
391 issue, _, err := gc.Issues.CreateIssue(
392 repositoryID,
393 &gitlab.CreateIssueOptions{
394 Title: &title,
395 Description: &body,
396 },
397 gitlab.WithContext(ctx),
398 )
399 if err != nil {
400 return 0, 0, "", err
401 }
402
403 return issue.ID, issue.IID, issue.WebURL, nil
404}
405
406// add a comment to an issue and return it ID
407func addCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) (int, error) {
408 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
409 defer cancel()
410 note, _, err := gc.Notes.CreateIssueNote(
411 repositoryID, issueID,
412 &gitlab.CreateIssueNoteOptions{
413 Body: &body,
414 },
415 gitlab.WithContext(ctx),
416 )
417 if err != nil {
418 return 0, err
419 }
420
421 return note.ID, nil
422}
423
424func editCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID, noteID int, body string) error {
425 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
426 defer cancel()
427 _, _, err := gc.Notes.UpdateIssueNote(
428 repositoryID, issueID, noteID,
429 &gitlab.UpdateIssueNoteOptions{
430 Body: &body,
431 },
432 gitlab.WithContext(ctx),
433 )
434
435 return err
436}
437
438func updateGitlabIssueStatus(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, status bug.Status) error {
439 var state string
440
441 switch status {
442 case bug.OpenStatus:
443 state = "reopen"
444 case bug.ClosedStatus:
445 state = "close"
446 default:
447 panic("unknown bug state")
448 }
449
450 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
451 defer cancel()
452 _, _, err := gc.Issues.UpdateIssue(
453 repositoryID, issueID,
454 &gitlab.UpdateIssueOptions{
455 StateEvent: &state,
456 },
457 gitlab.WithContext(ctx),
458 )
459
460 return err
461}
462
463func updateGitlabIssueBody(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) error {
464 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
465 defer cancel()
466 _, _, err := gc.Issues.UpdateIssue(
467 repositoryID, issueID,
468 &gitlab.UpdateIssueOptions{
469 Description: &body,
470 },
471 gitlab.WithContext(ctx),
472 )
473
474 return err
475}
476
477func updateGitlabIssueTitle(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, title string) error {
478 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
479 defer cancel()
480 _, _, err := gc.Issues.UpdateIssue(
481 repositoryID, issueID,
482 &gitlab.UpdateIssueOptions{
483 Title: &title,
484 },
485 gitlab.WithContext(ctx),
486 )
487
488 return err
489}
490
491// update gitlab. issue labels
492func updateGitlabIssueLabels(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, labels []string) error {
493 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
494 defer cancel()
495 _, _, err := gc.Issues.UpdateIssue(
496 repositoryID, issueID,
497 &gitlab.UpdateIssueOptions{
498 Labels: labels,
499 },
500 gitlab.WithContext(ctx),
501 )
502
503 return err
504}