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