import.go

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