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 != nil {
205 return nil, err
206 }
207
208 err = gi.ensureCommentEdit(repo, b, target, edit)
209 if err != nil {
210 return nil, err
211 }
212 }
213 }
214
215 return b, nil
216}
217
218func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
219
220 switch item.Typename {
221 case "IssueComment":
222 // collect all comment edits
223 var commentEdits []userContentEdit
224 for gi.iterator.NextCommentEdit() {
225 commentEdits = append(commentEdits, gi.iterator.CommentEditValue())
226 }
227
228 // ensureTimelineComment send import events over out chanel
229 err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
230 if err != nil {
231 return fmt.Errorf("timeline comment creation: %v", err)
232 }
233 return nil
234
235 case "LabeledEvent":
236 id := parseId(item.LabeledEvent.Id)
237 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
238 if err == nil {
239 return nil
240 }
241
242 if err != cache.ErrNoMatchingOp {
243 return err
244 }
245 author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
246 if err != nil {
247 return err
248 }
249 op, err := b.ForceChangeLabelsRaw(
250 author,
251 item.LabeledEvent.CreatedAt.Unix(),
252 []string{
253 string(item.LabeledEvent.Label.Name),
254 },
255 nil,
256 map[string]string{metaKeyGithubId: id},
257 )
258 if err != nil {
259 return err
260 }
261
262 gi.out <- core.NewImportLabelChange(op.Id())
263 return nil
264
265 case "UnlabeledEvent":
266 id := parseId(item.UnlabeledEvent.Id)
267 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
268 if err == nil {
269 return nil
270 }
271 if err != cache.ErrNoMatchingOp {
272 return err
273 }
274 author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
275 if err != nil {
276 return err
277 }
278
279 op, err := b.ForceChangeLabelsRaw(
280 author,
281 item.UnlabeledEvent.CreatedAt.Unix(),
282 nil,
283 []string{
284 string(item.UnlabeledEvent.Label.Name),
285 },
286 map[string]string{metaKeyGithubId: id},
287 )
288 if err != nil {
289 return err
290 }
291
292 gi.out <- core.NewImportLabelChange(op.Id())
293 return nil
294
295 case "ClosedEvent":
296 id := parseId(item.ClosedEvent.Id)
297 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
298 if err != cache.ErrNoMatchingOp {
299 return err
300 }
301 if err == nil {
302 return nil
303 }
304 author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
305 if err != nil {
306 return err
307 }
308 op, err := b.CloseRaw(
309 author,
310 item.ClosedEvent.CreatedAt.Unix(),
311 map[string]string{metaKeyGithubId: id},
312 )
313
314 if err != nil {
315 return err
316 }
317
318 gi.out <- core.NewImportStatusChange(op.Id())
319 return nil
320
321 case "ReopenedEvent":
322 id := parseId(item.ReopenedEvent.Id)
323 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
324 if err != cache.ErrNoMatchingOp {
325 return err
326 }
327 if err == nil {
328 return nil
329 }
330 author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
331 if err != nil {
332 return err
333 }
334 op, err := b.OpenRaw(
335 author,
336 item.ReopenedEvent.CreatedAt.Unix(),
337 map[string]string{metaKeyGithubId: id},
338 )
339
340 if err != nil {
341 return err
342 }
343
344 gi.out <- core.NewImportStatusChange(op.Id())
345 return nil
346
347 case "RenamedTitleEvent":
348 id := parseId(item.RenamedTitleEvent.Id)
349 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
350 if err != cache.ErrNoMatchingOp {
351 return err
352 }
353 if err == nil {
354 return nil
355 }
356 author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
357 if err != nil {
358 return err
359 }
360 op, err := b.SetTitleRaw(
361 author,
362 item.RenamedTitleEvent.CreatedAt.Unix(),
363 string(item.RenamedTitleEvent.CurrentTitle),
364 map[string]string{metaKeyGithubId: id},
365 )
366 if err != nil {
367 return err
368 }
369
370 gi.out <- core.NewImportTitleEdition(op.Id())
371 return nil
372 }
373
374 return nil
375}
376
377func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
378 // ensure person
379 author, err := gi.ensurePerson(repo, item.Author)
380 if err != nil {
381 return err
382 }
383
384 targetOpID, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(item.Id))
385 if err != nil && err != cache.ErrNoMatchingOp {
386 // real error
387 return err
388 }
389
390 // if no edits are given we create the comment
391 if len(edits) == 0 {
392 if err == cache.ErrNoMatchingOp {
393 cleanText, err := text.Cleanup(string(item.Body))
394 if err != nil {
395 return err
396 }
397
398 // add comment operation
399 op, err := b.AddCommentRaw(
400 author,
401 item.CreatedAt.Unix(),
402 cleanText,
403 nil,
404 map[string]string{
405 metaKeyGithubId: parseId(item.Id),
406 metaKeyGithubUrl: parseId(item.Url.String()),
407 },
408 )
409 if err != nil {
410 return err
411 }
412
413 gi.out <- core.NewImportComment(op.Id())
414 return nil
415 }
416
417 } else {
418 for i, edit := range edits {
419 if i == 0 && targetOpID != "" {
420 // The first edit in the github result is the comment creation itself, we already have that
421 continue
422 }
423
424 // ensure editor identity
425 editor, err := gi.ensurePerson(repo, edit.Editor)
426 if err != nil {
427 return err
428 }
429
430 // create comment when target is empty
431 if targetOpID == "" {
432 cleanText, err := text.Cleanup(string(*edit.Diff))
433 if err != nil {
434 return err
435 }
436
437 op, err := b.AddCommentRaw(
438 editor,
439 edit.CreatedAt.Unix(),
440 cleanText,
441 nil,
442 map[string]string{
443 metaKeyGithubId: parseId(item.Id),
444 metaKeyGithubUrl: item.Url.String(),
445 },
446 )
447 if err != nil {
448 return err
449 }
450 gi.out <- core.NewImportComment(op.Id())
451
452 // set target for the nexr edit now that the comment is created
453 targetOpID = op.Id()
454 continue
455 }
456
457 err = gi.ensureCommentEdit(repo, b, targetOpID, edit)
458 if err != nil {
459 return err
460 }
461 }
462 }
463 return nil
464}
465
466func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target entity.Id, edit userContentEdit) error {
467 _, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
468 if err == nil {
469 return nil
470 }
471 if err != cache.ErrNoMatchingOp {
472 // real error
473 return err
474 }
475
476 editor, err := gi.ensurePerson(repo, edit.Editor)
477 if err != nil {
478 return err
479 }
480
481 switch {
482 case edit.DeletedAt != nil:
483 // comment deletion, not supported yet
484 return nil
485
486 case edit.DeletedAt == nil:
487
488 cleanText, err := text.Cleanup(string(*edit.Diff))
489 if err != nil {
490 return err
491 }
492
493 // comment edition
494 op, err := b.EditCommentRaw(
495 editor,
496 edit.CreatedAt.Unix(),
497 target,
498 cleanText,
499 map[string]string{
500 metaKeyGithubId: parseId(edit.Id),
501 },
502 )
503
504 if err != nil {
505 return err
506 }
507
508 gi.out <- core.NewImportCommentEdition(op.Id())
509 return nil
510 }
511 return nil
512}
513
514// ensurePerson create a bug.Person from the Github data
515func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
516 // When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
517 // in it's UI. So we need a special case to get it.
518 if actor == nil {
519 return gi.getGhost(repo)
520 }
521
522 // Look first in the cache
523 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
524 if err == nil {
525 return i, nil
526 }
527 if entity.IsErrMultipleMatch(err) {
528 return nil, err
529 }
530
531 // importing a new identity
532
533 var name string
534 var email string
535
536 switch actor.Typename {
537 case "User":
538 if actor.User.Name != nil {
539 name = string(*(actor.User.Name))
540 }
541 email = string(actor.User.Email)
542 case "Organization":
543 if actor.Organization.Name != nil {
544 name = string(*(actor.Organization.Name))
545 }
546 if actor.Organization.Email != nil {
547 email = string(*(actor.Organization.Email))
548 }
549 case "Bot":
550 }
551
552 i, err = repo.NewIdentityRaw(
553 name,
554 email,
555 string(actor.Login),
556 string(actor.AvatarUrl),
557 map[string]string{
558 metaKeyGithubLogin: string(actor.Login),
559 },
560 )
561
562 if err != nil {
563 return nil, err
564 }
565
566 gi.out <- core.NewImportIdentity(i.Id())
567 return i, nil
568}
569
570func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
571 // Look first in the cache
572 i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, "ghost")
573 if err == nil {
574 return i, nil
575 }
576 if entity.IsErrMultipleMatch(err) {
577 return nil, err
578 }
579
580 var q ghostQuery
581
582 variables := map[string]interface{}{
583 "login": githubv4.String("ghost"),
584 }
585
586 ctx, cancel := context.WithTimeout(gi.iterator.ctx, defaultTimeout)
587 defer cancel()
588
589 err = gi.client.Query(ctx, &q, variables)
590 if err != nil {
591 return nil, err
592 }
593
594 var name string
595 if q.User.Name != nil {
596 name = string(*q.User.Name)
597 }
598
599 return repo.NewIdentityRaw(
600 name,
601 "",
602 string(q.User.Login),
603 string(q.User.AvatarUrl),
604 map[string]string{
605 metaKeyGithubLogin: string(q.User.Login),
606 },
607 )
608}
609
610// parseId convert the unusable githubv4.ID (an interface{}) into a string
611func parseId(id githubv4.ID) string {
612 return fmt.Sprintf("%v", id)
613}