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