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