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