import.go

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