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