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