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