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