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