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(_ context.Context, 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[confKeyOwner], gi.conf[confKeyProject], 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	// Name is not necessarily set, fallback to login as a name is required in the identity
538	if name == "" {
539		name = string(actor.Login)
540	}
541
542	i, err = repo.NewIdentityRaw(
543		name,
544		email,
545		string(actor.AvatarUrl),
546		map[string]string{
547			metaKeyGithubLogin: string(actor.Login),
548		},
549	)
550
551	if err != nil {
552		return nil, err
553	}
554
555	gi.out <- core.NewImportIdentity(i.Id())
556	return i, nil
557}
558
559func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
560	// Look first in the cache
561	i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, "ghost")
562	if err == nil {
563		return i, nil
564	}
565	if entity.IsErrMultipleMatch(err) {
566		return nil, err
567	}
568
569	var q ghostQuery
570
571	variables := map[string]interface{}{
572		"login": githubv4.String("ghost"),
573	}
574
575	ctx, cancel := context.WithTimeout(gi.iterator.ctx, defaultTimeout)
576	defer cancel()
577
578	err = gi.client.Query(ctx, &q, variables)
579	if err != nil {
580		return nil, err
581	}
582
583	var name string
584	if q.User.Name != nil {
585		name = string(*q.User.Name)
586	}
587
588	return repo.NewIdentityRaw(
589		name,
590		"",
591		string(q.User.AvatarUrl),
592		map[string]string{
593			metaKeyGithubLogin: string(q.User.Login),
594		},
595	)
596}
597
598// parseId convert the unusable githubv4.ID (an interface{}) into a string
599func parseId(id githubv4.ID) string {
600	return fmt.Sprintf("%v", id)
601}