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