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