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