1package github
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/MichaelMure/git-bug/bridge/core"
9 "github.com/MichaelMure/git-bug/bug"
10 "github.com/MichaelMure/git-bug/cache"
11 "github.com/MichaelMure/git-bug/identity"
12 "github.com/MichaelMure/git-bug/util/git"
13 "github.com/MichaelMure/git-bug/util/text"
14 "github.com/shurcooL/githubv4"
15)
16
17const (
18 keyGithubId = "github-id"
19 keyGithubUrl = "github-url"
20 keyGithubLogin = "github-login"
21)
22
23// githubImporter implement the Importer interface
24type githubImporter struct {
25 conf core.Configuration
26}
27
28func (gi *githubImporter) Init(conf core.Configuration) error {
29 gi.conf = conf
30 return nil
31}
32
33// ImportAll .
34func (gi *githubImporter) ImportAll(repo *cache.RepoCache, since time.Time) error {
35 iterator := NewIterator(gi.conf[keyUser], gi.conf[keyProject], gi.conf[keyToken], since)
36
37 // Loop over all matching issues
38 for iterator.NextIssue() {
39 issue := iterator.IssueValue()
40
41 fmt.Printf("importing issue: %v %v\n", iterator.importedIssues, issue.Title)
42 // get issue edits
43 issueEdits := []userContentEdit{}
44 for iterator.NextIssueEdit() {
45 // issueEdit.Diff == nil happen if the event is older than early 2018, Github doesn't have the data before that.
46 // Best we can do is to ignore the event.
47 if issueEdit := iterator.IssueEditValue(); issueEdit.Diff != nil && string(*issueEdit.Diff) != "" {
48 issueEdits = append(issueEdits, issueEdit)
49 }
50 }
51
52 // create issue
53 b, err := gi.ensureIssue(repo, issue, issueEdits)
54 if err != nil {
55 return fmt.Errorf("issue creation: %v", err)
56 }
57
58 // loop over timeline items
59 for iterator.NextTimeline() {
60 item := iterator.TimelineValue()
61
62 // if item is comment
63 if item.Typename == "IssueComment" {
64 // collect all edits
65 commentEdits := []userContentEdit{}
66 for iterator.NextCommentEdit() {
67 if commentEdit := iterator.CommentEditValue(); commentEdit.Diff != nil && string(*commentEdit.Diff) != "" {
68 commentEdits = append(commentEdits, commentEdit)
69 }
70 }
71
72 err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
73 if err != nil {
74 return fmt.Errorf("timeline comment creation: %v", err)
75 }
76
77 } else {
78 if err := gi.ensureTimelineItem(repo, b, item); err != nil {
79 return fmt.Errorf("timeline event creation: %v", err)
80 }
81 }
82 }
83
84 // commit bug state
85 if err := b.CommitAsNeeded(); err != nil {
86 return fmt.Errorf("bug commit: %v", err)
87 }
88 }
89
90 if err := iterator.Error(); err != nil {
91 fmt.Printf("import error: %v\n", err)
92 return err
93 }
94
95 fmt.Printf("Successfully imported %v issues from Github\n", iterator.ImportedIssues())
96 return nil
97}
98
99func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline, issueEdits []userContentEdit) (*cache.BugCache, error) {
100 // ensure issue author
101 author, err := gi.ensurePerson(repo, issue.Author)
102 if err != nil {
103 return nil, err
104 }
105
106 // resolve bug
107 b, err := repo.ResolveBugCreateMetadata(keyGithubUrl, issue.Url.String())
108 if err != nil && err != bug.ErrBugNotExist {
109 return nil, err
110 }
111
112 // if issueEdits is empty
113 if len(issueEdits) == 0 {
114 if err == bug.ErrBugNotExist {
115 cleanText, err := text.Cleanup(string(issue.Body))
116 if err != nil {
117 return nil, err
118 }
119
120 // create bug
121 b, err = repo.NewBugRaw(
122 author,
123 issue.CreatedAt.Unix(),
124 issue.Title,
125 cleanText,
126 nil,
127 map[string]string{
128 keyGithubId: parseId(issue.Id),
129 keyGithubUrl: issue.Url.String(),
130 })
131 if err != nil {
132 return nil, err
133 }
134 }
135
136 } else {
137 // create bug from given issueEdits
138 for i, edit := range issueEdits {
139 if i == 0 && b != nil {
140 // The first edit in the github result is the issue creation itself, we already have that
141 continue
142 }
143
144 cleanText, err := text.Cleanup(string(*edit.Diff))
145 if err != nil {
146 return nil, err
147 }
148
149 // if the bug doesn't exist
150 if b == nil {
151 // we create the bug as soon as we have a legit first edition
152 b, err = repo.NewBugRaw(
153 author,
154 issue.CreatedAt.Unix(),
155 issue.Title,
156 cleanText,
157 nil,
158 map[string]string{
159 keyGithubId: parseId(issue.Id),
160 keyGithubUrl: issue.Url.String(),
161 },
162 )
163
164 if err != nil {
165 return nil, err
166 }
167
168 continue
169 }
170
171 // other edits will be added as CommentEdit operations
172 target, err := b.ResolveOperationWithMetadata(keyGithubUrl, issue.Url.String())
173 if err != nil {
174 return nil, err
175 }
176
177 err = gi.ensureCommentEdit(repo, b, target, edit)
178 if err != nil {
179 return nil, err
180 }
181 }
182 }
183
184 return b, nil
185}
186
187func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
188 fmt.Printf("import event item: %s\n", item.Typename)
189
190 switch item.Typename {
191 case "IssueComment":
192
193 case "LabeledEvent":
194 id := parseId(item.LabeledEvent.Id)
195 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
196 if err != cache.ErrNoMatchingOp {
197 return err
198 }
199 author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
200 if err != nil {
201 return err
202 }
203 _, err = b.ForceChangeLabelsRaw(
204 author,
205 item.LabeledEvent.CreatedAt.Unix(),
206 []string{
207 string(item.LabeledEvent.Label.Name),
208 },
209 nil,
210 map[string]string{keyGithubId: id},
211 )
212
213 return err
214
215 case "UnlabeledEvent":
216 id := parseId(item.UnlabeledEvent.Id)
217 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
218 if err != cache.ErrNoMatchingOp {
219 return err
220 }
221 author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
222 if err != nil {
223 return err
224 }
225
226 _, err = b.ForceChangeLabelsRaw(
227 author,
228 item.UnlabeledEvent.CreatedAt.Unix(),
229 nil,
230 []string{
231 string(item.UnlabeledEvent.Label.Name),
232 },
233 map[string]string{keyGithubId: id},
234 )
235 return err
236
237 case "ClosedEvent":
238 id := parseId(item.ClosedEvent.Id)
239 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
240 if err != cache.ErrNoMatchingOp {
241 return err
242 }
243 author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
244 if err != nil {
245 return err
246 }
247 _, err = b.CloseRaw(
248 author,
249 item.ClosedEvent.CreatedAt.Unix(),
250 map[string]string{keyGithubId: id},
251 )
252 return err
253
254 case "ReopenedEvent":
255 id := parseId(item.ReopenedEvent.Id)
256 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
257 if err != cache.ErrNoMatchingOp {
258 return err
259 }
260 author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
261 if err != nil {
262 return err
263 }
264 _, err = b.OpenRaw(
265 author,
266 item.ReopenedEvent.CreatedAt.Unix(),
267 map[string]string{keyGithubId: id},
268 )
269 return err
270
271 case "RenamedTitleEvent":
272 id := parseId(item.RenamedTitleEvent.Id)
273 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
274 if err != cache.ErrNoMatchingOp {
275 return err
276 }
277 author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
278 if err != nil {
279 return err
280 }
281 _, err = b.SetTitleRaw(
282 author,
283 item.RenamedTitleEvent.CreatedAt.Unix(),
284 string(item.RenamedTitleEvent.CurrentTitle),
285 map[string]string{keyGithubId: id},
286 )
287 return err
288
289 default:
290 fmt.Printf("ignore event: %v\n", item.Typename)
291 }
292
293 return nil
294}
295
296func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
297 // ensure person
298 author, err := gi.ensurePerson(repo, item.Author)
299 if err != nil {
300 return err
301 }
302
303 var target git.Hash
304 target, err = b.ResolveOperationWithMetadata(keyGithubId, parseId(item.Id))
305 if err != nil && err != cache.ErrNoMatchingOp {
306 // real error
307 return err
308 }
309 // if no edits are given we create the comment
310 if len(edits) == 0 {
311
312 // if comment doesn't exist
313 if err == cache.ErrNoMatchingOp {
314 cleanText, err := text.Cleanup(string(item.Body))
315 if err != nil {
316 return err
317 }
318
319 // add comment operation
320 op, err := b.AddCommentRaw(
321 author,
322 item.CreatedAt.Unix(),
323 cleanText,
324 nil,
325 map[string]string{
326 keyGithubId: parseId(item.Id),
327 keyGithubUrl: parseId(item.Url.String()),
328 },
329 )
330 if err != nil {
331 return err
332 }
333
334 // set hash
335 target, err = op.Hash()
336 if err != nil {
337 return err
338 }
339 }
340 } else {
341 for i, edit := range edits {
342 if i == 0 && target != "" {
343 // The first edit in the github result is the comment creation itself, we already have that
344 continue
345 }
346
347 // ensure editor identity
348 editor, err := gi.ensurePerson(repo, edit.Editor)
349 if err != nil {
350 return err
351 }
352
353 // create comment when target is empty
354 if target == "" {
355 cleanText, err := text.Cleanup(string(*edit.Diff))
356 if err != nil {
357 return err
358 }
359
360 op, err := b.AddCommentRaw(
361 editor,
362 edit.CreatedAt.Unix(),
363 cleanText,
364 nil,
365 map[string]string{
366 keyGithubId: parseId(item.Id),
367 keyGithubUrl: item.Url.String(),
368 },
369 )
370 if err != nil {
371 return err
372 }
373
374 // set hash
375 target, err = op.Hash()
376 if err != nil {
377 return err
378 }
379
380 continue
381 }
382
383 err = gi.ensureCommentEdit(repo, b, target, edit)
384 if err != nil {
385 return err
386 }
387 }
388 }
389 return nil
390}
391
392func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target git.Hash, edit userContentEdit) error {
393 _, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(edit.Id))
394 if err == nil {
395 // already imported
396 return nil
397 }
398 if err != cache.ErrNoMatchingOp {
399 // real error
400 return err
401 }
402
403 fmt.Println("import edition")
404
405 editor, err := gi.ensurePerson(repo, edit.Editor)
406 if err != nil {
407 return err
408 }
409
410 switch {
411 case edit.DeletedAt != nil:
412 // comment deletion, not supported yet
413 fmt.Println("comment deletion is not supported yet")
414
415 case edit.DeletedAt == nil:
416
417 cleanText, err := text.Cleanup(string(*edit.Diff))
418 if err != nil {
419 return err
420 }
421
422 // comment edition
423 _, err = b.EditCommentRaw(
424 editor,
425 edit.CreatedAt.Unix(),
426 target,
427 cleanText,
428 map[string]string{
429 keyGithubId: parseId(edit.Id),
430 },
431 )
432
433 if err != nil {
434 return err
435 }
436 }
437
438 return nil
439}
440
441// ensurePerson create a bug.Person from the Github data
442func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
443 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
444 // in it's UI. So we need a special case to get it.
445 if actor == nil {
446 return gi.getGhost(repo)
447 }
448
449 // Look first in the cache
450 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login))
451 if err == nil {
452 return i, nil
453 }
454 if _, ok := err.(identity.ErrMultipleMatch); ok {
455 return nil, err
456 }
457
458 var name string
459 var email string
460
461 switch actor.Typename {
462 case "User":
463 if actor.User.Name != nil {
464 name = string(*(actor.User.Name))
465 }
466 email = string(actor.User.Email)
467 case "Organization":
468 if actor.Organization.Name != nil {
469 name = string(*(actor.Organization.Name))
470 }
471 if actor.Organization.Email != nil {
472 email = string(*(actor.Organization.Email))
473 }
474 case "Bot":
475 }
476
477 return repo.NewIdentityRaw(
478 name,
479 email,
480 string(actor.Login),
481 string(actor.AvatarUrl),
482 map[string]string{
483 keyGithubLogin: string(actor.Login),
484 },
485 )
486}
487
488func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
489 // Look first in the cache
490 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost")
491 if err == nil {
492 return i, nil
493 }
494 if _, ok := err.(identity.ErrMultipleMatch); ok {
495 return nil, err
496 }
497
498 var q userQuery
499
500 variables := map[string]interface{}{
501 "login": githubv4.String("ghost"),
502 }
503
504 gc := buildClient(gi.conf[keyToken])
505
506 err = gc.Query(context.TODO(), &q, variables)
507 if err != nil {
508 return nil, err
509 }
510
511 var name string
512 if q.User.Name != nil {
513 name = string(*q.User.Name)
514 }
515
516 return repo.NewIdentityRaw(
517 name,
518 string(q.User.Email),
519 string(q.User.Login),
520 string(q.User.AvatarUrl),
521 map[string]string{
522 keyGithubLogin: string(q.User.Login),
523 },
524 )
525}
526
527// parseId convert the unusable githubv4.ID (an interface{}) into a string
528func parseId(id githubv4.ID) string {
529 return fmt.Sprintf("%v", id)
530}
531
532func reverseEdits(edits []userContentEdit) []userContentEdit {
533 for i, j := 0, len(edits)-1; i < j; i, j = i+1, j-1 {
534 edits[i], edits[j] = edits[j], edits[i]
535 }
536 return edits
537}