export.go

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