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