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