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}