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