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