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