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