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