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
 42// ImportAll iterate over all the configured repository issues and ensure the creation of the
 43// missing issues / timeline items / edits / label events ...
 44func (gi *githubImporter) ImportAll(repo *cache.RepoCache, since time.Time) error {
 45	gi.iterator = NewIterator(gi.conf[keyUser], gi.conf[keyProject], gi.conf[keyToken], since)
 46
 47	// Loop over all matching issues
 48	for gi.iterator.NextIssue() {
 49		issue := gi.iterator.IssueValue()
 50		fmt.Printf("importing issue: %v\n", issue.Title)
 51
 52		// create issue
 53		b, err := gi.ensureIssue(repo, issue)
 54		if err != nil {
 55			return fmt.Errorf("issue creation: %v", err)
 56		}
 57
 58		// loop over timeline items
 59		for gi.iterator.NextTimelineItem() {
 60			if err := gi.ensureTimelineItem(repo, b, gi.iterator.TimelineItemValue()); err != nil {
 61				return fmt.Errorf("timeline item creation: %v", err)
 62			}
 63		}
 64
 65		// commit bug state
 66		if err := b.CommitAsNeeded(); err != nil {
 67			return fmt.Errorf("bug commit: %v", err)
 68		}
 69	}
 70
 71	if err := gi.iterator.Error(); err != nil {
 72		fmt.Printf("import error: %v\n", err)
 73		return err
 74	}
 75
 76	fmt.Printf("Successfully imported %d issues and %d identities from Github\n", gi.importedIssues, gi.importedIdentities)
 77	return nil
 78}
 79
 80func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) {
 81	// ensure issue author
 82	author, err := gi.ensurePerson(repo, issue.Author)
 83	if err != nil {
 84		return nil, err
 85	}
 86
 87	// resolve bug
 88	b, err := repo.ResolveBugCreateMetadata(keyGithubUrl, issue.Url.String())
 89	if err != nil && err != bug.ErrBugNotExist {
 90		return nil, err
 91	}
 92
 93	// get issue edits
 94	issueEdits := []userContentEdit{}
 95	for gi.iterator.NextIssueEdit() {
 96		issueEdits = append(issueEdits, gi.iterator.IssueEditValue())
 97	}
 98
 99	// if issueEdits is empty
100	if len(issueEdits) == 0 {
101		if err == bug.ErrBugNotExist {
102			cleanText, err := text.Cleanup(string(issue.Body))
103			if err != nil {
104				return nil, err
105			}
106
107			// create bug
108			b, err = repo.NewBugRaw(
109				author,
110				issue.CreatedAt.Unix(),
111				issue.Title,
112				cleanText,
113				nil,
114				map[string]string{
115					keyGithubId:  parseId(issue.Id),
116					keyGithubUrl: issue.Url.String(),
117				})
118			if err != nil {
119				return nil, err
120			}
121
122			// importing a new bug
123			gi.importedIssues++
124		}
125
126	} else {
127		// create bug from given issueEdits
128		for i, edit := range issueEdits {
129			if i == 0 && b != nil {
130				// The first edit in the github result is the issue creation itself, we already have that
131				continue
132			}
133
134			cleanText, err := text.Cleanup(string(*edit.Diff))
135			if err != nil {
136				return nil, err
137			}
138
139			// if the bug doesn't exist
140			if b == nil {
141				// we create the bug as soon as we have a legit first edition
142				b, err = repo.NewBugRaw(
143					author,
144					issue.CreatedAt.Unix(),
145					issue.Title,
146					cleanText,
147					nil,
148					map[string]string{
149						keyGithubId:  parseId(issue.Id),
150						keyGithubUrl: issue.Url.String(),
151					},
152				)
153
154				if err != nil {
155					return nil, err
156				}
157
158				// importing a new bug
159				gi.importedIssues++
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}