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/entity"
14 "github.com/MichaelMure/git-bug/util/text"
15)
16
17const (
18 keyOrigin = "origin"
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 var 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 keyOrigin: target,
117 keyGithubId: parseId(issue.Id),
118 keyGithubUrl: issue.Url.String(),
119 })
120 if err != nil {
121 return nil, err
122 }
123
124 // importing a new bug
125 gi.importedIssues++
126 }
127
128 } else {
129 // create bug from given issueEdits
130 for i, edit := range issueEdits {
131 if i == 0 && b != nil {
132 // The first edit in the github result is the issue creation itself, we already have that
133 continue
134 }
135
136 cleanText, err := text.Cleanup(string(*edit.Diff))
137 if err != nil {
138 return nil, err
139 }
140
141 // if the bug doesn't exist
142 if b == nil {
143 // we create the bug as soon as we have a legit first edition
144 b, _, err = repo.NewBugRaw(
145 author,
146 issue.CreatedAt.Unix(),
147 issue.Title,
148 cleanText,
149 nil,
150 map[string]string{
151 keyOrigin: target,
152 keyGithubId: parseId(issue.Id),
153 keyGithubUrl: issue.Url.String(),
154 },
155 )
156
157 if err != nil {
158 return nil, err
159 }
160
161 // importing a new bug
162 gi.importedIssues++
163
164 continue
165 }
166
167 // other edits will be added as CommentEdit operations
168 target, err := b.ResolveOperationWithMetadata(keyGithubUrl, issue.Url.String())
169 if err != nil {
170 return nil, err
171 }
172
173 err = gi.ensureCommentEdit(repo, b, target, edit)
174 if err != nil {
175 return nil, err
176 }
177 }
178 }
179
180 return b, nil
181}
182
183func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
184 fmt.Printf("import event item: %s\n", item.Typename)
185
186 switch item.Typename {
187 case "IssueComment":
188 // collect all comment edits
189 commentEdits := []userContentEdit{}
190 for gi.iterator.NextCommentEdit() {
191 commentEdits = append(commentEdits, gi.iterator.CommentEditValue())
192 }
193
194 err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
195 if err != nil {
196 return fmt.Errorf("timeline comment creation: %v", err)
197 }
198
199 case "LabeledEvent":
200 id := parseId(item.LabeledEvent.Id)
201 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
202 if err != cache.ErrNoMatchingOp {
203 return err
204 }
205 author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
206 if err != nil {
207 return err
208 }
209 _, err = b.ForceChangeLabelsRaw(
210 author,
211 item.LabeledEvent.CreatedAt.Unix(),
212 []string{
213 string(item.LabeledEvent.Label.Name),
214 },
215 nil,
216 map[string]string{keyGithubId: id},
217 )
218
219 return err
220
221 case "UnlabeledEvent":
222 id := parseId(item.UnlabeledEvent.Id)
223 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
224 if err != cache.ErrNoMatchingOp {
225 return err
226 }
227 author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
228 if err != nil {
229 return err
230 }
231
232 _, err = b.ForceChangeLabelsRaw(
233 author,
234 item.UnlabeledEvent.CreatedAt.Unix(),
235 nil,
236 []string{
237 string(item.UnlabeledEvent.Label.Name),
238 },
239 map[string]string{keyGithubId: id},
240 )
241 return err
242
243 case "ClosedEvent":
244 id := parseId(item.ClosedEvent.Id)
245 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
246 if err != cache.ErrNoMatchingOp {
247 return err
248 }
249 author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
250 if err != nil {
251 return err
252 }
253 _, err = b.CloseRaw(
254 author,
255 item.ClosedEvent.CreatedAt.Unix(),
256 map[string]string{keyGithubId: id},
257 )
258 return err
259
260 case "ReopenedEvent":
261 id := parseId(item.ReopenedEvent.Id)
262 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
263 if err != cache.ErrNoMatchingOp {
264 return err
265 }
266 author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
267 if err != nil {
268 return err
269 }
270 _, err = b.OpenRaw(
271 author,
272 item.ReopenedEvent.CreatedAt.Unix(),
273 map[string]string{keyGithubId: id},
274 )
275 return err
276
277 case "RenamedTitleEvent":
278 id := parseId(item.RenamedTitleEvent.Id)
279 _, err := b.ResolveOperationWithMetadata(keyGithubId, id)
280 if err != cache.ErrNoMatchingOp {
281 return err
282 }
283 author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
284 if err != nil {
285 return err
286 }
287 _, err = b.SetTitleRaw(
288 author,
289 item.RenamedTitleEvent.CreatedAt.Unix(),
290 string(item.RenamedTitleEvent.CurrentTitle),
291 map[string]string{keyGithubId: id},
292 )
293 return err
294
295 default:
296 fmt.Printf("ignore event: %v\n", item.Typename)
297 }
298
299 return nil
300}
301
302func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
303 // ensure person
304 author, err := gi.ensurePerson(repo, item.Author)
305 if err != nil {
306 return err
307 }
308
309 targetOpID, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(item.Id))
310 if err != nil && err != cache.ErrNoMatchingOp {
311 // real error
312 return err
313 }
314
315 // if no edits are given we create the comment
316 if len(edits) == 0 {
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 _, 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 return err
336 }
337 } else {
338 for i, edit := range edits {
339 if i == 0 && targetOpID != "" {
340 // The first edit in the github result is the comment creation itself, we already have that
341 continue
342 }
343
344 // ensure editor identity
345 editor, err := gi.ensurePerson(repo, edit.Editor)
346 if err != nil {
347 return err
348 }
349
350 // create comment when target is empty
351 if targetOpID == "" {
352 cleanText, err := text.Cleanup(string(*edit.Diff))
353 if err != nil {
354 return err
355 }
356
357 op, err := b.AddCommentRaw(
358 editor,
359 edit.CreatedAt.Unix(),
360 cleanText,
361 nil,
362 map[string]string{
363 keyGithubId: parseId(item.Id),
364 keyGithubUrl: item.Url.String(),
365 },
366 )
367 if err != nil {
368 return err
369 }
370
371 // set target for the nexr edit now that the comment is created
372 targetOpID = op.Id()
373
374 continue
375 }
376
377 err = gi.ensureCommentEdit(repo, b, targetOpID, edit)
378 if err != nil {
379 return err
380 }
381 }
382 }
383 return nil
384}
385
386func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target entity.Id, edit userContentEdit) error {
387 _, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(edit.Id))
388 if err == nil {
389 // already imported
390 return nil
391 }
392 if err != cache.ErrNoMatchingOp {
393 // real error
394 return err
395 }
396
397 fmt.Println("import edition")
398
399 editor, err := gi.ensurePerson(repo, edit.Editor)
400 if err != nil {
401 return err
402 }
403
404 switch {
405 case edit.DeletedAt != nil:
406 // comment deletion, not supported yet
407 fmt.Println("comment deletion is not supported yet")
408
409 case edit.DeletedAt == nil:
410
411 cleanText, err := text.Cleanup(string(*edit.Diff))
412 if err != nil {
413 return err
414 }
415
416 // comment edition
417 _, err = b.EditCommentRaw(
418 editor,
419 edit.CreatedAt.Unix(),
420 target,
421 cleanText,
422 map[string]string{
423 keyGithubId: parseId(edit.Id),
424 },
425 )
426
427 if err != nil {
428 return err
429 }
430 }
431
432 return nil
433}
434
435// ensurePerson create a bug.Person from the Github data
436func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
437 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
438 // in it's UI. So we need a special case to get it.
439 if actor == nil {
440 return gi.getGhost(repo)
441 }
442
443 // Look first in the cache
444 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login))
445 if err == nil {
446 return i, nil
447 }
448 if _, ok := err.(entity.ErrMultipleMatch); ok {
449 return nil, err
450 }
451
452 // importing a new identity
453 gi.importedIdentities++
454
455 var name string
456 var email string
457
458 switch actor.Typename {
459 case "User":
460 if actor.User.Name != nil {
461 name = string(*(actor.User.Name))
462 }
463 email = string(actor.User.Email)
464 case "Organization":
465 if actor.Organization.Name != nil {
466 name = string(*(actor.Organization.Name))
467 }
468 if actor.Organization.Email != nil {
469 email = string(*(actor.Organization.Email))
470 }
471 case "Bot":
472 }
473
474 return repo.NewIdentityRaw(
475 name,
476 email,
477 string(actor.Login),
478 string(actor.AvatarUrl),
479 map[string]string{
480 keyGithubLogin: string(actor.Login),
481 },
482 )
483}
484
485func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
486 // Look first in the cache
487 i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost")
488 if err == nil {
489 return i, nil
490 }
491 if _, ok := err.(entity.ErrMultipleMatch); ok {
492 return nil, err
493 }
494
495 var q ghostQuery
496
497 variables := map[string]interface{}{
498 "login": githubv4.String("ghost"),
499 }
500
501 gc := buildClient(gi.conf[keyToken])
502
503 parentCtx := context.Background()
504 ctx, cancel := context.WithTimeout(parentCtx, defaultTimeout)
505 defer cancel()
506
507 err = gc.Query(ctx, &q, variables)
508 if err != nil {
509 return nil, err
510 }
511
512 var name string
513 if q.User.Name != nil {
514 name = string(*q.User.Name)
515 }
516
517 return repo.NewIdentityRaw(
518 name,
519 "",
520 string(q.User.Login),
521 string(q.User.AvatarUrl),
522 map[string]string{
523 keyGithubLogin: string(q.User.Login),
524 },
525 )
526}
527
528// parseId convert the unusable githubv4.ID (an interface{}) into a string
529func parseId(id githubv4.ID) string {
530 return fmt.Sprintf("%v", id)
531}