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