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 err == identity.ErrIdentityNotExist {
 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(op.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(op.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"), op.Target)
319					return
320				}
321
322				commentIDint, err := strconv.Atoi(commentID)
323				if err != nil {
324					out <- core.NewExportError(fmt.Errorf("unexpected comment id format"), op.Target)
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(op.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(op.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(op.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(op.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}