import.go

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