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