import.go

  1package github
  2
  3import (
  4	"context"
  5	"fmt"
  6	"strings"
  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/util/git"
 12	"github.com/shurcooL/githubv4"
 13)
 14
 15const keyGithubId = "github-id"
 16const keyGithubUrl = "github-url"
 17
 18// githubImporter implement the Importer interface
 19type githubImporter struct{}
 20
 21func (*githubImporter) ImportAll(repo *cache.RepoCache, conf core.Configuration) error {
 22	client := buildClient(conf)
 23
 24	q := &issueTimelineQuery{}
 25	variables := map[string]interface{}{
 26		"owner":         githubv4.String(conf[keyUser]),
 27		"name":          githubv4.String(conf[keyProject]),
 28		"issueFirst":    githubv4.Int(1),
 29		"issueAfter":    (*githubv4.String)(nil),
 30		"timelineFirst": githubv4.Int(10),
 31		"timelineAfter": (*githubv4.String)(nil),
 32
 33		// Fun fact, github provide the comment edition in reverse chronological
 34		// order, because haha. Look at me, I'm dying of laughter.
 35		"issueEditLast":     githubv4.Int(10),
 36		"issueEditBefore":   (*githubv4.String)(nil),
 37		"commentEditLast":   githubv4.Int(10),
 38		"commentEditBefore": (*githubv4.String)(nil),
 39	}
 40
 41	var b *cache.BugCache
 42
 43	for {
 44		err := client.Query(context.TODO(), &q, variables)
 45		if err != nil {
 46			return err
 47		}
 48
 49		if len(q.Repository.Issues.Nodes) == 0 {
 50			return nil
 51		}
 52
 53		issue := q.Repository.Issues.Nodes[0]
 54
 55		if b == nil {
 56			b, err = ensureIssue(repo, issue, client, variables)
 57			if err != nil {
 58				return err
 59			}
 60		}
 61
 62		for _, itemEdge := range q.Repository.Issues.Nodes[0].Timeline.Edges {
 63			ensureTimelineItem(b, itemEdge.Cursor, itemEdge.Node, client, variables)
 64		}
 65
 66		if !issue.Timeline.PageInfo.HasNextPage {
 67			err = b.CommitAsNeeded()
 68			if err != nil {
 69				return err
 70			}
 71
 72			b = nil
 73
 74			if !q.Repository.Issues.PageInfo.HasNextPage {
 75				break
 76			}
 77
 78			variables["issueAfter"] = githubv4.NewString(q.Repository.Issues.PageInfo.EndCursor)
 79			variables["timelineAfter"] = (*githubv4.String)(nil)
 80			continue
 81		}
 82
 83		variables["timelineAfter"] = githubv4.NewString(issue.Timeline.PageInfo.EndCursor)
 84	}
 85
 86	return nil
 87}
 88
 89func (*githubImporter) Import(repo *cache.RepoCache, conf core.Configuration, id string) error {
 90	fmt.Println(conf)
 91	fmt.Println("IMPORT")
 92
 93	return nil
 94}
 95
 96func ensureIssue(repo *cache.RepoCache, issue issueTimeline, client *githubv4.Client, rootVariables map[string]interface{}) (*cache.BugCache, error) {
 97	fmt.Printf("import issue: %s\n", issue.Title)
 98
 99	b, err := repo.ResolveBugCreateMetadata(keyGithubId, parseId(issue.Id))
100	if err != nil && err != bug.ErrBugNotExist {
101		return nil, err
102	}
103
104	// if there is no edit, the UserContentEdits given by github is empty. That
105	// means that the original message is given by the issue message.
106
107	// if there is edits, the UserContentEdits given by github contains both the
108	// original message and the following edits. The issue message give the last
109	// version so we don't care about that.
110
111	if len(issue.UserContentEdits.Nodes) == 0 {
112		if err == bug.ErrBugNotExist {
113			b, err = repo.NewBugRaw(
114				makePerson(issue.Author),
115				issue.CreatedAt.Unix(),
116				// Todo: this might not be the initial title, we need to query the
117				// timeline to be sure
118				issue.Title,
119				cleanupText(string(issue.Body)),
120				nil,
121				map[string]string{
122					keyGithubId:  parseId(issue.Id),
123					keyGithubUrl: issue.Url.String(),
124				},
125			)
126
127			if err != nil {
128				return nil, err
129			}
130		}
131
132		return b, nil
133	}
134
135	// reverse the order, because github
136	reverseEdits(issue.UserContentEdits.Nodes)
137
138	if err == bug.ErrBugNotExist {
139		firstEdit := issue.UserContentEdits.Nodes[0]
140
141		if firstEdit.Diff == nil {
142			return nil, fmt.Errorf("no diff")
143		}
144
145		b, err = repo.NewBugRaw(
146			makePerson(issue.Author),
147			issue.CreatedAt.Unix(),
148			// Todo: this might not be the initial title, we need to query the
149			// timeline to be sure
150			issue.Title,
151			cleanupText(string(*firstEdit.Diff)),
152			nil,
153			map[string]string{
154				keyGithubId:  parseId(issue.Id),
155				keyGithubUrl: issue.Url.String(),
156			},
157		)
158		if err != nil {
159			return nil, err
160		}
161	}
162
163	target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(issue.Id))
164	if err != nil {
165		return nil, err
166	}
167
168	for i, edit := range issue.UserContentEdits.Nodes {
169		if i == 0 {
170			// The first edit in the github result is the creation itself, we already have that
171			continue
172		}
173
174		err := ensureCommentEdit(b, target, edit)
175		if err != nil {
176			return nil, err
177		}
178	}
179
180	if !issue.UserContentEdits.PageInfo.HasNextPage {
181		return b, nil
182	}
183
184	// We have more edit, querying them
185
186	q := &issueEditQuery{}
187	variables := map[string]interface{}{
188		"owner":           rootVariables["owner"],
189		"name":            rootVariables["name"],
190		"issueFirst":      rootVariables["issueFirst"],
191		"issueAfter":      rootVariables["issueAfter"],
192		"issueEditLast":   githubv4.Int(10),
193		"issueEditBefore": issue.UserContentEdits.PageInfo.StartCursor,
194	}
195
196	for {
197		err := client.Query(context.TODO(), &q, variables)
198		if err != nil {
199			return nil, err
200		}
201
202		edits := q.Repository.Issues.Nodes[0].UserContentEdits
203
204		if len(edits.Nodes) == 0 {
205			return b, nil
206		}
207
208		for i, edit := range edits.Nodes {
209			if i == 0 {
210				// The first edit in the github result is the creation itself, we already have that
211				continue
212			}
213
214			err := ensureCommentEdit(b, target, edit)
215			if err != nil {
216				return nil, err
217			}
218		}
219
220		if !edits.PageInfo.HasNextPage {
221			break
222		}
223
224		variables["issueEditBefore"] = edits.PageInfo.StartCursor
225	}
226
227	// TODO: check + import files
228
229	return b, nil
230}
231
232func ensureTimelineItem(b *cache.BugCache, cursor githubv4.String, item timelineItem, client *githubv4.Client, rootVariables map[string]interface{}) error {
233	fmt.Printf("import %s\n", item.Typename)
234
235	switch item.Typename {
236	case "IssueComment":
237		return ensureComment(b, cursor, item.IssueComment, client, rootVariables)
238
239	case "LabeledEvent":
240		id := parseId(item.LabeledEvent.Id)
241		_, err := b.ResolveTargetWithMetadata(keyGithubId, id)
242		if err != cache.ErrNoMatchingOp {
243			return err
244		}
245		_, err = b.ChangeLabelsRaw(
246			makePerson(item.LabeledEvent.Actor),
247			item.LabeledEvent.CreatedAt.Unix(),
248			[]string{
249				string(item.LabeledEvent.Label.Name),
250			},
251			nil,
252			map[string]string{keyGithubId: id},
253		)
254		return err
255
256	case "UnlabeledEvent":
257		id := parseId(item.UnlabeledEvent.Id)
258		_, err := b.ResolveTargetWithMetadata(keyGithubId, id)
259		if err != cache.ErrNoMatchingOp {
260			return err
261		}
262		_, err = b.ChangeLabelsRaw(
263			makePerson(item.UnlabeledEvent.Actor),
264			item.UnlabeledEvent.CreatedAt.Unix(),
265			nil,
266			[]string{
267				string(item.UnlabeledEvent.Label.Name),
268			},
269			map[string]string{keyGithubId: id},
270		)
271		return err
272
273	case "ClosedEvent":
274		id := parseId(item.ClosedEvent.Id)
275		_, err := b.ResolveTargetWithMetadata(keyGithubId, id)
276		if err != cache.ErrNoMatchingOp {
277			return err
278		}
279		return b.CloseRaw(
280			makePerson(item.ClosedEvent.Actor),
281			item.ClosedEvent.CreatedAt.Unix(),
282			map[string]string{keyGithubId: id},
283		)
284
285	case "ReopenedEvent":
286		id := parseId(item.ReopenedEvent.Id)
287		_, err := b.ResolveTargetWithMetadata(keyGithubId, id)
288		if err != cache.ErrNoMatchingOp {
289			return err
290		}
291		return b.OpenRaw(
292			makePerson(item.ReopenedEvent.Actor),
293			item.ReopenedEvent.CreatedAt.Unix(),
294			map[string]string{keyGithubId: id},
295		)
296
297	case "RenamedTitleEvent":
298		id := parseId(item.RenamedTitleEvent.Id)
299		_, err := b.ResolveTargetWithMetadata(keyGithubId, id)
300		if err != cache.ErrNoMatchingOp {
301			return err
302		}
303		return b.SetTitleRaw(
304			makePerson(item.RenamedTitleEvent.Actor),
305			item.RenamedTitleEvent.CreatedAt.Unix(),
306			string(item.RenamedTitleEvent.CurrentTitle),
307			map[string]string{keyGithubId: id},
308		)
309
310	default:
311		fmt.Println("ignore event ", item.Typename)
312	}
313
314	return nil
315}
316
317func ensureComment(b *cache.BugCache, cursor githubv4.String, comment issueComment, client *githubv4.Client, rootVariables map[string]interface{}) error {
318	target, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id))
319	if err != nil && err != cache.ErrNoMatchingOp {
320		// real error
321		return err
322	}
323
324	// if there is no edit, the UserContentEdits given by github is empty. That
325	// means that the original message is given by the comment message.
326
327	// if there is edits, the UserContentEdits given by github contains both the
328	// original message and the following edits. The comment message give the last
329	// version so we don't care about that.
330
331	if len(comment.UserContentEdits.Nodes) == 0 {
332		if err == cache.ErrNoMatchingOp {
333			err = b.AddCommentRaw(
334				makePerson(comment.Author),
335				comment.CreatedAt.Unix(),
336				cleanupText(string(comment.Body)),
337				nil,
338				map[string]string{
339					keyGithubId: parseId(comment.Id),
340				},
341			)
342
343			if err != nil {
344				return err
345			}
346		}
347
348		return nil
349	}
350
351	// reverse the order, because github
352	reverseEdits(comment.UserContentEdits.Nodes)
353
354	if err == cache.ErrNoMatchingOp {
355		firstEdit := comment.UserContentEdits.Nodes[0]
356
357		if firstEdit.Diff == nil {
358			return fmt.Errorf("no diff")
359		}
360
361		err = b.AddCommentRaw(
362			makePerson(comment.Author),
363			comment.CreatedAt.Unix(),
364			cleanupText(string(*firstEdit.Diff)),
365			nil,
366			map[string]string{
367				keyGithubId:  parseId(comment.Id),
368				keyGithubUrl: comment.Url.String(),
369			},
370		)
371		if err != nil {
372			return err
373		}
374
375		target, err = b.ResolveTargetWithMetadata(keyGithubId, parseId(comment.Id))
376		if err != nil {
377			return err
378		}
379	}
380
381	for i, edit := range comment.UserContentEdits.Nodes {
382		if i == 0 {
383			// The first edit in the github result is the comment creation itself, we already have that
384			continue
385		}
386
387		err := ensureCommentEdit(b, target, edit)
388		if err != nil {
389			return err
390		}
391	}
392
393	if !comment.UserContentEdits.PageInfo.HasNextPage {
394		return nil
395	}
396
397	// We have more edit, querying them
398
399	q := &commentEditQuery{}
400	variables := map[string]interface{}{
401		"owner":             rootVariables["owner"],
402		"name":              rootVariables["name"],
403		"issueFirst":        rootVariables["issueFirst"],
404		"issueAfter":        rootVariables["issueAfter"],
405		"timelineFirst":     githubv4.Int(1),
406		"timelineAfter":     cursor,
407		"commentEditLast":   githubv4.Int(10),
408		"commentEditBefore": comment.UserContentEdits.PageInfo.StartCursor,
409	}
410
411	for {
412		err := client.Query(context.TODO(), &q, variables)
413		if err != nil {
414			return err
415		}
416
417		edits := q.Repository.Issues.Nodes[0].Timeline.Nodes[0].IssueComment.UserContentEdits
418
419		if len(edits.Nodes) == 0 {
420			return nil
421		}
422
423		for i, edit := range edits.Nodes {
424			if i == 0 {
425				// The first edit in the github result is the creation itself, we already have that
426				continue
427			}
428
429			err := ensureCommentEdit(b, target, edit)
430			if err != nil {
431				return err
432			}
433		}
434
435		if !edits.PageInfo.HasNextPage {
436			break
437		}
438
439		variables["commentEditBefore"] = edits.PageInfo.StartCursor
440	}
441
442	// TODO: check + import files
443
444	return nil
445}
446
447func ensureCommentEdit(b *cache.BugCache, target git.Hash, edit userContentEdit) error {
448	if edit.Editor == nil {
449		return fmt.Errorf("no editor")
450	}
451
452	if edit.Diff == nil {
453		return fmt.Errorf("no diff")
454	}
455
456	_, err := b.ResolveTargetWithMetadata(keyGithubId, parseId(edit.Id))
457	if err == nil {
458		// already imported
459		return nil
460	}
461	if err != cache.ErrNoMatchingOp {
462		// real error
463		return err
464	}
465
466	fmt.Printf("import edition\n")
467
468	switch {
469	case edit.DeletedAt != nil:
470		// comment deletion, not supported yet
471
472	case edit.DeletedAt == nil:
473		// comment edition
474		err := b.EditCommentRaw(
475			makePerson(*edit.Editor),
476			edit.CreatedAt.Unix(),
477			target,
478			cleanupText(string(*edit.Diff)),
479			map[string]string{
480				keyGithubId: parseId(edit.Id),
481			},
482		)
483		if err != nil {
484			return err
485		}
486	}
487
488	return nil
489}
490
491// makePerson create a bug.Person from the Github data
492func makePerson(actor actor) bug.Person {
493	return bug.Person{
494		Name:      string(actor.Login),
495		AvatarUrl: string(actor.AvatarUrl),
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}