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 } 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(fmt.Sprintf("unexpected gitlab id format: %s", bugGitlabIDString))
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 labelSet := make(map[string]struct{})
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 := op.(type) {
242 case *bug.AddCommentOperation:
243
244 // send operation to gitlab
245 id, err = addCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, op.Message)
246 if err != nil {
247 err := errors.Wrap(err, "adding comment")
248 out <- core.NewExportError(err, b.Id())
249 return
250 }
251
252 out <- core.NewExportComment(op.Id())
253
254 idString = strconv.Itoa(id)
255 // cache comment id
256 ge.cachedOperationIDs[op.Id().String()] = idString
257
258 case *bug.EditCommentOperation:
259 targetId := op.Target.String()
260
261 // Since gitlab doesn't consider the issue body as a comment
262 if targetId == bugCreationId {
263
264 // case bug creation operation: we need to edit the Gitlab issue
265 if err := updateGitlabIssueBody(ctx, client, ge.repositoryID, bugGitlabID, op.Message); err != nil {
266 err := errors.Wrap(err, "editing issue")
267 out <- core.NewExportError(err, b.Id())
268 return
269 }
270
271 out <- core.NewExportCommentEdition(op.Id())
272 id = bugGitlabID
273
274 } else {
275
276 // case comment edition operation: we need to edit the Gitlab comment
277 commentID, ok := ge.cachedOperationIDs[targetId]
278 if !ok {
279 out <- core.NewExportError(fmt.Errorf("unexpected error: comment id not found"), op.Target)
280 return
281 }
282
283 commentIDint, err := strconv.Atoi(commentID)
284 if err != nil {
285 out <- core.NewExportError(fmt.Errorf("unexpected comment id format"), op.Target)
286 return
287 }
288
289 if err := editCommentGitlabIssue(ctx, client, ge.repositoryID, bugGitlabID, commentIDint, op.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 if err := updateGitlabIssueStatus(ctx, client, ge.repositoryID, bugGitlabID, op.Status); err != nil {
301 err := errors.Wrap(err, "editing status")
302 out <- core.NewExportError(err, b.Id())
303 return
304 }
305
306 out <- core.NewExportStatusChange(op.Id())
307 id = bugGitlabID
308
309 case *bug.SetTitleOperation:
310 if err := updateGitlabIssueTitle(ctx, client, ge.repositoryID, bugGitlabID, op.Title); err != nil {
311 err := errors.Wrap(err, "editing title")
312 out <- core.NewExportError(err, b.Id())
313 return
314 }
315
316 out <- core.NewExportTitleEdition(op.Id())
317 id = bugGitlabID
318
319 case *bug.LabelChangeOperation:
320 // we need to set the actual list of labels at each label change operation
321 // because gitlab update issue requests need directly the latest list of the verison
322
323 for _, label := range op.Added {
324 labelSet[label.String()] = struct{}{}
325 }
326
327 for _, label := range op.Removed {
328 delete(labelSet, label.String())
329 }
330
331 labels := make([]string, 0, len(labelSet))
332 for key := range labelSet {
333 labels = append(labels, key)
334 }
335
336 if err := updateGitlabIssueLabels(ctx, client, ge.repositoryID, bugGitlabID, labels); err != nil {
337 err := errors.Wrap(err, "updating labels")
338 out <- core.NewExportError(err, b.Id())
339 return
340 }
341
342 out <- core.NewExportLabelChange(op.Id())
343 id = bugGitlabID
344 default:
345 panic("unhandled operation type case")
346 }
347
348 idString = strconv.Itoa(id)
349 // mark operation as exported
350 if err := markOperationAsExported(b, op.Id(), idString, url); err != nil {
351 err := errors.Wrap(err, "marking operation as exported")
352 out <- core.NewExportError(err, b.Id())
353 return
354 }
355
356 // commit at each operation export to avoid exporting same events multiple times
357 if err := b.CommitAsNeeded(); err != nil {
358 err := errors.Wrap(err, "bug commit")
359 out <- core.NewExportError(err, b.Id())
360 return
361 }
362 }
363}
364
365func markOperationAsExported(b *cache.BugCache, target entity.Id, gitlabID, gitlabURL string) error {
366 _, err := b.SetMetadata(
367 target,
368 map[string]string{
369 keyGitlabId: gitlabID,
370 keyGitlabUrl: gitlabURL,
371 },
372 )
373
374 return err
375}
376
377// create a gitlab. issue and return it ID
378func createGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID, title, body string) (int, int, string, error) {
379 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
380 defer cancel()
381 issue, _, err := gc.Issues.CreateIssue(
382 repositoryID,
383 &gitlab.CreateIssueOptions{
384 Title: &title,
385 Description: &body,
386 },
387 gitlab.WithContext(ctx),
388 )
389 if err != nil {
390 return 0, 0, "", err
391 }
392
393 return issue.ID, issue.IID, issue.WebURL, nil
394}
395
396// add a comment to an issue and return it ID
397func addCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) (int, error) {
398 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
399 defer cancel()
400 note, _, err := gc.Notes.CreateIssueNote(
401 repositoryID, issueID,
402 &gitlab.CreateIssueNoteOptions{
403 Body: &body,
404 },
405 gitlab.WithContext(ctx),
406 )
407 if err != nil {
408 return 0, err
409 }
410
411 return note.ID, nil
412}
413
414func editCommentGitlabIssue(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID, noteID int, body string) error {
415 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
416 defer cancel()
417 _, _, err := gc.Notes.UpdateIssueNote(
418 repositoryID, issueID, noteID,
419 &gitlab.UpdateIssueNoteOptions{
420 Body: &body,
421 },
422 gitlab.WithContext(ctx),
423 )
424
425 return err
426}
427
428func updateGitlabIssueStatus(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, status bug.Status) error {
429 var state string
430
431 switch status {
432 case bug.OpenStatus:
433 state = "reopen"
434 case bug.ClosedStatus:
435 state = "close"
436 default:
437 panic("unknown bug state")
438 }
439
440 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
441 defer cancel()
442 _, _, err := gc.Issues.UpdateIssue(
443 repositoryID, issueID,
444 &gitlab.UpdateIssueOptions{
445 StateEvent: &state,
446 },
447 gitlab.WithContext(ctx),
448 )
449
450 return err
451}
452
453func updateGitlabIssueBody(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, body string) error {
454 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
455 defer cancel()
456 _, _, err := gc.Issues.UpdateIssue(
457 repositoryID, issueID,
458 &gitlab.UpdateIssueOptions{
459 Description: &body,
460 },
461 gitlab.WithContext(ctx),
462 )
463
464 return err
465}
466
467func updateGitlabIssueTitle(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, title string) error {
468 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
469 defer cancel()
470 _, _, err := gc.Issues.UpdateIssue(
471 repositoryID, issueID,
472 &gitlab.UpdateIssueOptions{
473 Title: &title,
474 },
475 gitlab.WithContext(ctx),
476 )
477
478 return err
479}
480
481// update gitlab. issue labels
482func updateGitlabIssueLabels(ctx context.Context, gc *gitlab.Client, repositoryID string, issueID int, labels []string) error {
483 ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
484 defer cancel()
485 _, _, err := gc.Issues.UpdateIssue(
486 repositoryID, issueID,
487 &gitlab.UpdateIssueOptions{
488 Labels: labels,
489 },
490 gitlab.WithContext(ctx),
491 )
492
493 return err
494}