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/bug"
 12	"github.com/MichaelMure/git-bug/cache"
 13	"github.com/MichaelMure/git-bug/identity"
 14	"github.com/MichaelMure/git-bug/util/git"
 15	"github.com/MichaelMure/git-bug/util/text"
 16)
 17
 18const (
 19	keyGithubId    = "github-id"
 20	keyGithubUrl   = "github-url"
 21	keyGithubLogin = "github-login"
 22)
 23
 24// githubImporter implement the Importer interface
 25type githubImporter struct {
 26	conf core.Configuration
 27
 28	// iterator
 29	iterator *iterator
 30
 31	// number of imported issues
 32	importedIssues int
 33
 34	// number of imported identities
 35	importedIdentities int
 36}
 37
 38func (gi *githubImporter) Init(conf core.Configuration) error {
 39	gi.conf = conf
 40	return nil
 41}
 42
 43// ImportAll iterate over all the configured repository issues and ensure the creation of the
 44// missing issues / timeline items / edits / label events ...
 45func (gi *githubImporter) ImportAll(repo *cache.RepoCache, since time.Time) error {
 46	gi.iterator = NewIterator(gi.conf[keyOwner], gi.conf[keyProject], gi.conf[keyToken], since)
 47
 48	// Loop over all matching issues
 49	for gi.iterator.NextIssue() {
 50		issue := gi.iterator.IssueValue()
 51		fmt.Printf("importing issue: %v\n", issue.Title)
 52
 53		// create issue
 54		b, err := gi.ensureIssue(repo, issue)
 55		if err != nil {
 56			return fmt.Errorf("issue creation: %v", err)
 57		}
 58
 59		// loop over timeline items
 60		for gi.iterator.NextTimelineItem() {
 61			if err := gi.ensureTimelineItem(repo, b, gi.iterator.TimelineItemValue()); err != nil {
 62				return fmt.Errorf("timeline item creation: %v", err)
 63			}
 64		}
 65
 66		// commit bug state
 67		if err := b.CommitAsNeeded(); err != nil {
 68			return fmt.Errorf("bug commit: %v", err)
 69		}
 70	}
 71
 72	if err := gi.iterator.Error(); err != nil {
 73		fmt.Printf("import error: %v\n", err)
 74		return err
 75	}
 76
 77	fmt.Printf("Successfully imported %d issues and %d identities from Github\n", gi.importedIssues, gi.importedIdentities)
 78	return nil
 79}
 80
 81func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) {
 82	// ensure issue author
 83	author, err := gi.ensurePerson(repo, issue.Author)
 84	if err != nil {
 85		return nil, err
 86	}
 87
 88	// resolve bug
 89	b, err := repo.ResolveBugCreateMetadata(keyGithubUrl, issue.Url.String())
 90	if err != nil && err != bug.ErrBugNotExist {
 91		return nil, err
 92	}
 93
 94	// get issue edits
 95	issueEdits := []userContentEdit{}
 96	for gi.iterator.NextIssueEdit() {
 97		issueEdits = append(issueEdits, gi.iterator.IssueEditValue())
 98	}
 99
100	// if issueEdits is empty
101	if len(issueEdits) == 0 {
102		if err == bug.ErrBugNotExist {
103			cleanText, err := text.Cleanup(string(issue.Body))
104			if err != nil {
105				return nil, err
106			}
107
108			// create bug
109			b, _, err = repo.NewBugRaw(
110				author,
111				issue.CreatedAt.Unix(),
112				issue.Title,
113				cleanText,
114				nil,
115				map[string]string{
116					keyGithubId:  parseId(issue.Id),
117					keyGithubUrl: issue.Url.String(),
118				})
119			if err != nil {
120				return nil, err
121			}
122
123			// importing a new bug
124			gi.importedIssues++
125		}
126
127	} else {
128		// create bug from given issueEdits
129		for i, edit := range issueEdits {
130			if i == 0 && b != nil {
131				// The first edit in the github result is the issue creation itself, we already have that
132				continue
133			}
134
135			cleanText, err := text.Cleanup(string(*edit.Diff))
136			if err != nil {
137				return nil, err
138			}
139
140			// if the bug doesn't exist
141			if b == nil {
142				// we create the bug as soon as we have a legit first edition
143				b, _, err = repo.NewBugRaw(
144					author,
145					issue.CreatedAt.Unix(),
146					issue.Title,
147					cleanText,
148					nil,
149					map[string]string{
150						keyGithubId:  parseId(issue.Id),
151						keyGithubUrl: issue.Url.String(),
152					},
153				)
154
155				if err != nil {
156					return nil, err
157				}
158
159				// importing a new bug
160				gi.importedIssues++
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}