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