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