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