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