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