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