import.go

  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}