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