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