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