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