export.go

  1package github
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io/ioutil"
  9	"net/http"
 10	"os"
 11	"strings"
 12	"time"
 13
 14	"github.com/pkg/errors"
 15	"github.com/shurcooL/githubv4"
 16	"golang.org/x/sync/errgroup"
 17
 18	"github.com/MichaelMure/git-bug/bridge/core"
 19	"github.com/MichaelMure/git-bug/bridge/core/auth"
 20	"github.com/MichaelMure/git-bug/bug"
 21	"github.com/MichaelMure/git-bug/cache"
 22	"github.com/MichaelMure/git-bug/entity"
 23	"github.com/MichaelMure/git-bug/identity"
 24)
 25
 26var (
 27	ErrMissingIdentityToken = errors.New("missing identity token")
 28)
 29
 30// githubExporter implement the Exporter interface
 31type githubExporter struct {
 32	conf core.Configuration
 33
 34	// cache identities clients
 35	identityClient map[entity.Id]*githubv4.Client
 36
 37	// the client to use for non user-specific queries
 38	// should be the client of the default user
 39	defaultClient *githubv4.Client
 40
 41	// the token of the default user
 42	defaultToken *auth.Token
 43
 44	// github repository ID
 45	repositoryID string
 46
 47	// cache identifiers used to speed up exporting operations
 48	// cleared for each bug
 49	cachedOperationIDs map[entity.Id]string
 50
 51	// cache labels used to speed up exporting labels events
 52	cachedLabels map[string]string
 53}
 54
 55// Init .
 56func (ge *githubExporter) Init(repo *cache.RepoCache, conf core.Configuration) error {
 57	ge.conf = conf
 58	ge.identityClient = make(map[entity.Id]*githubv4.Client)
 59	ge.cachedOperationIDs = make(map[entity.Id]string)
 60	ge.cachedLabels = make(map[string]string)
 61
 62	user, err := repo.GetUserIdentity()
 63	if err != nil {
 64		return err
 65	}
 66
 67	// preload all clients
 68	err = ge.cacheAllClient(repo)
 69	if err != nil {
 70		return err
 71	}
 72
 73	ge.defaultClient, err = ge.getClientForIdentity(user.Id())
 74	if err != nil {
 75		return err
 76	}
 77
 78	login := user.ImmutableMetadata()[metaKeyGithubLogin]
 79	creds, err := auth.List(repo, auth.WithMeta(metaKeyGithubLogin, login), auth.WithTarget(target), auth.WithKind(auth.KindToken))
 80	if err != nil {
 81		return err
 82	}
 83
 84	if len(creds) == 0 {
 85		return ErrMissingIdentityToken
 86	}
 87
 88	ge.defaultToken = creds[0].(*auth.Token)
 89
 90	return nil
 91}
 92
 93func (ge *githubExporter) cacheAllClient(repo *cache.RepoCache) error {
 94	creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
 95	if err != nil {
 96		return err
 97	}
 98
 99	for _, cred := range creds {
100		login, ok := cred.GetMetadata(auth.MetaKeyLogin)
101		if !ok {
102			_, _ = fmt.Fprintf(os.Stderr, "credential %s is not tagged with Github login\n", cred.ID().Human())
103			continue
104		}
105
106		user, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, login)
107		if err == identity.ErrIdentityNotExist {
108			continue
109		}
110
111		if _, ok := ge.identityClient[user.Id()]; !ok {
112			client := buildClient(creds[0].(*auth.Token))
113			ge.identityClient[user.Id()] = client
114		}
115	}
116
117	return nil
118}
119
120// getClientForIdentity return a githubv4 API client configured with the access token of the given identity.
121func (ge *githubExporter) getClientForIdentity(userId entity.Id) (*githubv4.Client, error) {
122	client, ok := ge.identityClient[userId]
123	if ok {
124		return client, nil
125	}
126
127	return nil, ErrMissingIdentityToken
128}
129
130// ExportAll export all event made by the current user to Github
131func (ge *githubExporter) ExportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) {
132	out := make(chan core.ExportResult)
133
134	var err error
135	// get repository node id
136	ge.repositoryID, err = getRepositoryNodeID(
137		ctx,
138		ge.defaultToken,
139		ge.conf[keyOwner],
140		ge.conf[keyProject],
141	)
142	if err != nil {
143		return nil, err
144	}
145
146	// query all labels
147	err = ge.cacheGithubLabels(ctx, ge.defaultClient)
148	if err != nil {
149		return nil, err
150	}
151
152	go func() {
153		defer close(out)
154
155		allIdentitiesIds := make([]entity.Id, 0, len(ge.identityClient))
156		for id := range ge.identityClient {
157			allIdentitiesIds = append(allIdentitiesIds, id)
158		}
159
160		allBugsIds := repo.AllBugsIds()
161
162		for _, id := range allBugsIds {
163			b, err := repo.ResolveBug(id)
164			if err != nil {
165				out <- core.NewExportError(errors.Wrap(err, "can't load bug"), id)
166				return
167			}
168
169			select {
170
171			case <-ctx.Done():
172				// stop iterating if context cancel function is called
173				return
174
175			default:
176				snapshot := b.Snapshot()
177
178				// ignore issues created before since date
179				// TODO: compare the Lamport time instead of using the unix time
180				if snapshot.CreatedAt.Before(since) {
181					out <- core.NewExportNothing(b.Id(), "bug created before the since date")
182					continue
183				}
184
185				if snapshot.HasAnyActor(allIdentitiesIds...) {
186					// try to export the bug and it associated events
187					ge.exportBug(ctx, b, since, out)
188				}
189			}
190		}
191	}()
192
193	return out, nil
194}
195
196// exportBug publish bugs and related events
197func (ge *githubExporter) exportBug(ctx context.Context, b *cache.BugCache, since time.Time, out chan<- core.ExportResult) {
198	snapshot := b.Snapshot()
199	var bugUpdated bool
200
201	var bugGithubID string
202	var bugGithubURL string
203
204	// Special case:
205	// if a user try to export a bug that is not already exported to Github (or imported
206	// from Github) and we do not have the token of the bug author, there is nothing we can do.
207
208	// first operation is always createOp
209	createOp := snapshot.Operations[0].(*bug.CreateOperation)
210	author := snapshot.Author
211
212	// skip bug if origin is not allowed
213	origin, ok := snapshot.GetCreateMetadata(core.MetaKeyOrigin)
214	if ok && origin != target {
215		out <- core.NewExportNothing(b.Id(), fmt.Sprintf("issue tagged with origin: %s", origin))
216		return
217	}
218
219	// get github bug ID
220	githubID, ok := snapshot.GetCreateMetadata(metaKeyGithubId)
221	if ok {
222		githubURL, ok := snapshot.GetCreateMetadata(metaKeyGithubUrl)
223		if !ok {
224			// if we find github ID, github URL must be found too
225			err := fmt.Errorf("incomplete Github metadata: expected to find issue URL")
226			out <- core.NewExportError(err, b.Id())
227		}
228
229		// extract owner and project
230		owner, project, err := splitURL(githubURL)
231		if err != nil {
232			err := fmt.Errorf("bad project url: %v", err)
233			out <- core.NewExportError(err, b.Id())
234			return
235		}
236
237		// ignore issue coming from other repositories
238		if owner != ge.conf[keyOwner] && project != ge.conf[keyProject] {
239			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("skipping issue from url:%s", githubURL))
240			return
241		}
242
243		// will be used to mark operation related to a bug as exported
244		bugGithubID = githubID
245		bugGithubURL = githubURL
246
247	} else {
248		// check that we have a token for operation author
249		client, err := ge.getClientForIdentity(author.Id())
250		if err != nil {
251			// if bug is still not exported and we do not have the author stop the execution
252			out <- core.NewExportNothing(b.Id(), fmt.Sprintf("missing author token"))
253			return
254		}
255
256		// create bug
257		id, url, err := createGithubIssue(ctx, client, ge.repositoryID, createOp.Title, createOp.Message)
258		if err != nil {
259			err := errors.Wrap(err, "exporting github issue")
260			out <- core.NewExportError(err, b.Id())
261			return
262		}
263
264		out <- core.NewExportBug(b.Id())
265
266		// mark bug creation operation as exported
267		if err := markOperationAsExported(b, createOp.Id(), id, url); err != nil {
268			err := errors.Wrap(err, "marking operation as exported")
269			out <- core.NewExportError(err, b.Id())
270			return
271		}
272
273		// commit operation to avoid creating multiple issues with multiple pushes
274		if err := b.CommitAsNeeded(); err != nil {
275			err := errors.Wrap(err, "bug commit")
276			out <- core.NewExportError(err, b.Id())
277			return
278		}
279
280		// cache bug github ID and URL
281		bugGithubID = id
282		bugGithubURL = url
283	}
284
285	// cache operation github id
286	ge.cachedOperationIDs[createOp.Id()] = bugGithubID
287
288	for _, op := range snapshot.Operations[1:] {
289		// ignore SetMetadata operations
290		if _, ok := op.(*bug.SetMetadataOperation); ok {
291			continue
292		}
293
294		// ignore operations already existing in github (due to import or export)
295		// cache the ID of already exported or imported issues and events from Github
296		if id, ok := op.GetMetadata(metaKeyGithubId); ok {
297			ge.cachedOperationIDs[op.Id()] = id
298			continue
299		}
300
301		opAuthor := op.GetAuthor()
302		client, err := ge.getClientForIdentity(opAuthor.Id())
303		if err != nil {
304			continue
305		}
306
307		var id, url string
308		switch op := op.(type) {
309		case *bug.AddCommentOperation:
310			// send operation to github
311			id, url, err = addCommentGithubIssue(ctx, client, bugGithubID, op.Message)
312			if err != nil {
313				err := errors.Wrap(err, "adding comment")
314				out <- core.NewExportError(err, b.Id())
315				return
316			}
317
318			out <- core.NewExportComment(op.Id())
319
320			// cache comment id
321			ge.cachedOperationIDs[op.Id()] = id
322
323		case *bug.EditCommentOperation:
324			// Since github doesn't consider the issue body as a comment
325			if op.Target == createOp.Id() {
326
327				// case bug creation operation: we need to edit the Github issue
328				if err := updateGithubIssueBody(ctx, client, bugGithubID, op.Message); err != nil {
329					err := errors.Wrap(err, "editing issue")
330					out <- core.NewExportError(err, b.Id())
331					return
332				}
333
334				out <- core.NewExportCommentEdition(op.Id())
335
336				id = bugGithubID
337				url = bugGithubURL
338
339			} else {
340
341				// case comment edition operation: we need to edit the Github comment
342				commentID, ok := ge.cachedOperationIDs[op.Target]
343				if !ok {
344					panic("unexpected error: comment id not found")
345				}
346
347				eid, eurl, err := editCommentGithubIssue(ctx, client, commentID, op.Message)
348				if err != nil {
349					err := errors.Wrap(err, "editing comment")
350					out <- core.NewExportError(err, b.Id())
351					return
352				}
353
354				out <- core.NewExportCommentEdition(op.Id())
355
356				// use comment id/url instead of issue id/url
357				id = eid
358				url = eurl
359			}
360
361		case *bug.SetStatusOperation:
362			if err := updateGithubIssueStatus(ctx, client, bugGithubID, op.Status); err != nil {
363				err := errors.Wrap(err, "editing status")
364				out <- core.NewExportError(err, b.Id())
365				return
366			}
367
368			out <- core.NewExportStatusChange(op.Id())
369
370			id = bugGithubID
371			url = bugGithubURL
372
373		case *bug.SetTitleOperation:
374			if err := updateGithubIssueTitle(ctx, client, bugGithubID, op.Title); err != nil {
375				err := errors.Wrap(err, "editing title")
376				out <- core.NewExportError(err, b.Id())
377				return
378			}
379
380			out <- core.NewExportTitleEdition(op.Id())
381
382			id = bugGithubID
383			url = bugGithubURL
384
385		case *bug.LabelChangeOperation:
386			if err := ge.updateGithubIssueLabels(ctx, client, bugGithubID, op.Added, op.Removed); err != nil {
387				err := errors.Wrap(err, "updating labels")
388				out <- core.NewExportError(err, b.Id())
389				return
390			}
391
392			out <- core.NewExportLabelChange(op.Id())
393
394			id = bugGithubID
395			url = bugGithubURL
396
397		default:
398			panic("unhandled operation type case")
399		}
400
401		// mark operation as exported
402		if err := markOperationAsExported(b, op.Id(), id, url); err != nil {
403			err := errors.Wrap(err, "marking operation as exported")
404			out <- core.NewExportError(err, b.Id())
405			return
406		}
407
408		// commit at each operation export to avoid exporting same events multiple times
409		if err := b.CommitAsNeeded(); err != nil {
410			err := errors.Wrap(err, "bug commit")
411			out <- core.NewExportError(err, b.Id())
412			return
413		}
414
415		bugUpdated = true
416	}
417
418	if !bugUpdated {
419		out <- core.NewExportNothing(b.Id(), "nothing has been exported")
420	}
421}
422
423// getRepositoryNodeID request github api v3 to get repository node id
424func getRepositoryNodeID(ctx context.Context, token *auth.Token, owner, project string) (string, error) {
425	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
426	client := &http.Client{}
427
428	req, err := http.NewRequest("GET", url, nil)
429	if err != nil {
430		return "", err
431	}
432
433	// need the token for private repositories
434	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
435
436	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
437	defer cancel()
438	req = req.WithContext(ctx)
439
440	resp, err := client.Do(req)
441	if err != nil {
442		return "", err
443	}
444
445	if resp.StatusCode != http.StatusOK {
446		return "", fmt.Errorf("HTTP error %v retrieving repository node id", resp.StatusCode)
447	}
448
449	aux := struct {
450		NodeID string `json:"node_id"`
451	}{}
452
453	data, _ := ioutil.ReadAll(resp.Body)
454	err = resp.Body.Close()
455	if err != nil {
456		return "", err
457	}
458
459	err = json.Unmarshal(data, &aux)
460	if err != nil {
461		return "", err
462	}
463
464	return aux.NodeID, nil
465}
466
467func markOperationAsExported(b *cache.BugCache, target entity.Id, githubID, githubURL string) error {
468	_, err := b.SetMetadata(
469		target,
470		map[string]string{
471			metaKeyGithubId:  githubID,
472			metaKeyGithubUrl: githubURL,
473		},
474	)
475
476	return err
477}
478
479func (ge *githubExporter) cacheGithubLabels(ctx context.Context, gc *githubv4.Client) error {
480	variables := map[string]interface{}{
481		"owner": githubv4.String(ge.conf[keyOwner]),
482		"name":  githubv4.String(ge.conf[keyProject]),
483		"first": githubv4.Int(10),
484		"after": (*githubv4.String)(nil),
485	}
486
487	q := labelsQuery{}
488
489	hasNextPage := true
490	for hasNextPage {
491		// create a new timeout context at each iteration
492		ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
493
494		if err := gc.Query(ctx, &q, variables); err != nil {
495			cancel()
496			return err
497		}
498		cancel()
499
500		for _, label := range q.Repository.Labels.Nodes {
501			ge.cachedLabels[label.Name] = label.ID
502		}
503
504		hasNextPage = q.Repository.Labels.PageInfo.HasNextPage
505		variables["after"] = q.Repository.Labels.PageInfo.EndCursor
506	}
507
508	return nil
509}
510
511func (ge *githubExporter) getLabelID(gc *githubv4.Client, label string) (string, error) {
512	label = strings.ToLower(label)
513	for cachedLabel, ID := range ge.cachedLabels {
514		if label == strings.ToLower(cachedLabel) {
515			return ID, nil
516		}
517	}
518
519	return "", fmt.Errorf("didn't find label id in cache")
520}
521
522// create a new label and return it github id
523// NOTE: since createLabel mutation is still in preview mode we use github api v3 to create labels
524// see https://developer.github.com/v4/mutation/createlabel/ and https://developer.github.com/v4/previews/#labels-preview
525func (ge *githubExporter) createGithubLabel(ctx context.Context, label, color string) (string, error) {
526	url := fmt.Sprintf("%s/repos/%s/%s/labels", githubV3Url, ge.conf[keyOwner], ge.conf[keyProject])
527	client := &http.Client{}
528
529	params := struct {
530		Name        string `json:"name"`
531		Color       string `json:"color"`
532		Description string `json:"description"`
533	}{
534		Name:  label,
535		Color: color,
536	}
537
538	data, err := json.Marshal(params)
539	if err != nil {
540		return "", err
541	}
542
543	req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
544	if err != nil {
545		return "", err
546	}
547
548	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
549	defer cancel()
550	req = req.WithContext(ctx)
551
552	// need the token for private repositories
553	req.Header.Set("Authorization", fmt.Sprintf("token %s", ge.defaultToken.Value))
554
555	resp, err := client.Do(req)
556	if err != nil {
557		return "", err
558	}
559
560	if resp.StatusCode != http.StatusCreated {
561		return "", fmt.Errorf("error creating label: response status %v", resp.StatusCode)
562	}
563
564	aux := struct {
565		ID     int    `json:"id"`
566		NodeID string `json:"node_id"`
567		Color  string `json:"color"`
568	}{}
569
570	data, _ = ioutil.ReadAll(resp.Body)
571	defer resp.Body.Close()
572
573	err = json.Unmarshal(data, &aux)
574	if err != nil {
575		return "", err
576	}
577
578	return aux.NodeID, nil
579}
580
581/**
582// create github label using api v4
583func (ge *githubExporter) createGithubLabelV4(gc *githubv4.Client, label, labelColor string) (string, error) {
584	m := createLabelMutation{}
585	input := createLabelInput{
586		RepositoryID: ge.repositoryID,
587		Name:         githubv4.String(label),
588		Color:        githubv4.String(labelColor),
589	}
590
591	parentCtx := context.Background()
592	ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
593	defer cancel()
594
595	if err := gc.Mutate(ctx, &m, input, nil); err != nil {
596		return "", err
597	}
598
599	return m.CreateLabel.Label.ID, nil
600}
601*/
602
603func (ge *githubExporter) getOrCreateGithubLabelID(ctx context.Context, gc *githubv4.Client, repositoryID string, label bug.Label) (string, error) {
604	// try to get label id from cache
605	labelID, err := ge.getLabelID(gc, string(label))
606	if err == nil {
607		return labelID, nil
608	}
609
610	// RGBA to hex color
611	rgba := label.Color().RGBA()
612	hexColor := fmt.Sprintf("%.2x%.2x%.2x", rgba.R, rgba.G, rgba.B)
613
614	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
615	defer cancel()
616
617	labelID, err = ge.createGithubLabel(ctx, string(label), hexColor)
618	if err != nil {
619		return "", err
620	}
621
622	return labelID, nil
623}
624
625func (ge *githubExporter) getLabelsIDs(ctx context.Context, gc *githubv4.Client, repositoryID string, labels []bug.Label) ([]githubv4.ID, error) {
626	ids := make([]githubv4.ID, 0, len(labels))
627	var err error
628
629	// check labels ids
630	for _, label := range labels {
631		id, ok := ge.cachedLabels[string(label)]
632		if !ok {
633			// try to query label id
634			id, err = ge.getOrCreateGithubLabelID(ctx, gc, repositoryID, label)
635			if err != nil {
636				return nil, errors.Wrap(err, "get or create github label")
637			}
638
639			// cache label id
640			ge.cachedLabels[string(label)] = id
641		}
642
643		ids = append(ids, githubv4.ID(id))
644	}
645
646	return ids, nil
647}
648
649// create a github issue and return it ID
650func createGithubIssue(ctx context.Context, gc *githubv4.Client, repositoryID, title, body string) (string, string, error) {
651	m := &createIssueMutation{}
652	input := githubv4.CreateIssueInput{
653		RepositoryID: repositoryID,
654		Title:        githubv4.String(title),
655		Body:         (*githubv4.String)(&body),
656	}
657
658	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
659	defer cancel()
660
661	if err := gc.Mutate(ctx, m, input, nil); err != nil {
662		return "", "", err
663	}
664
665	issue := m.CreateIssue.Issue
666	return issue.ID, issue.URL, nil
667}
668
669// add a comment to an issue and return it ID
670func addCommentGithubIssue(ctx context.Context, gc *githubv4.Client, subjectID string, body string) (string, string, error) {
671	m := &addCommentToIssueMutation{}
672	input := githubv4.AddCommentInput{
673		SubjectID: subjectID,
674		Body:      githubv4.String(body),
675	}
676
677	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
678	defer cancel()
679
680	if err := gc.Mutate(ctx, m, input, nil); err != nil {
681		return "", "", err
682	}
683
684	node := m.AddComment.CommentEdge.Node
685	return node.ID, node.URL, nil
686}
687
688func editCommentGithubIssue(ctx context.Context, gc *githubv4.Client, commentID, body string) (string, string, error) {
689	m := &updateIssueCommentMutation{}
690	input := githubv4.UpdateIssueCommentInput{
691		ID:   commentID,
692		Body: githubv4.String(body),
693	}
694
695	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
696	defer cancel()
697
698	if err := gc.Mutate(ctx, m, input, nil); err != nil {
699		return "", "", err
700	}
701
702	return commentID, m.UpdateIssueComment.IssueComment.URL, nil
703}
704
705func updateGithubIssueStatus(ctx context.Context, gc *githubv4.Client, id string, status bug.Status) error {
706	m := &updateIssueMutation{}
707
708	// set state
709	var state githubv4.IssueState
710
711	switch status {
712	case bug.OpenStatus:
713		state = githubv4.IssueStateOpen
714	case bug.ClosedStatus:
715		state = githubv4.IssueStateClosed
716	default:
717		panic("unknown bug state")
718	}
719
720	input := githubv4.UpdateIssueInput{
721		ID:    id,
722		State: &state,
723	}
724
725	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
726	defer cancel()
727
728	if err := gc.Mutate(ctx, m, input, nil); err != nil {
729		return err
730	}
731
732	return nil
733}
734
735func updateGithubIssueBody(ctx context.Context, gc *githubv4.Client, id string, body string) error {
736	m := &updateIssueMutation{}
737	input := githubv4.UpdateIssueInput{
738		ID:   id,
739		Body: (*githubv4.String)(&body),
740	}
741
742	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
743	defer cancel()
744
745	if err := gc.Mutate(ctx, m, input, nil); err != nil {
746		return err
747	}
748
749	return nil
750}
751
752func updateGithubIssueTitle(ctx context.Context, gc *githubv4.Client, id, title string) error {
753	m := &updateIssueMutation{}
754	input := githubv4.UpdateIssueInput{
755		ID:    id,
756		Title: (*githubv4.String)(&title),
757	}
758
759	ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
760	defer cancel()
761
762	if err := gc.Mutate(ctx, m, input, nil); err != nil {
763		return err
764	}
765
766	return nil
767}
768
769// update github issue labels
770func (ge *githubExporter) updateGithubIssueLabels(ctx context.Context, gc *githubv4.Client, labelableID string, added, removed []bug.Label) error {
771	reqCtx, cancel := context.WithTimeout(ctx, defaultTimeout)
772	defer cancel()
773
774	wg, ctx := errgroup.WithContext(ctx)
775	if len(added) > 0 {
776		wg.Go(func() error {
777			addedIDs, err := ge.getLabelsIDs(ctx, gc, labelableID, added)
778			if err != nil {
779				return err
780			}
781
782			m := &addLabelsToLabelableMutation{}
783			inputAdd := githubv4.AddLabelsToLabelableInput{
784				LabelableID: labelableID,
785				LabelIDs:    addedIDs,
786			}
787
788			// add labels
789			if err := gc.Mutate(reqCtx, m, inputAdd, nil); err != nil {
790				return err
791			}
792			return nil
793		})
794	}
795
796	if len(removed) > 0 {
797		wg.Go(func() error {
798			removedIDs, err := ge.getLabelsIDs(ctx, gc, labelableID, removed)
799			if err != nil {
800				return err
801			}
802
803			m2 := &removeLabelsFromLabelableMutation{}
804			inputRemove := githubv4.RemoveLabelsFromLabelableInput{
805				LabelableID: labelableID,
806				LabelIDs:    removedIDs,
807			}
808
809			// remove label labels
810			if err := gc.Mutate(reqCtx, m2, inputRemove, nil); err != nil {
811				return err
812			}
813			return nil
814		})
815	}
816
817	return wg.Wait()
818}