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