export.go

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