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