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