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