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