import.go

  1package github
  2
  3import (
  4	"context"
  5	"fmt"
  6	"time"
  7
  8	"github.com/shurcooL/githubv4"
  9
 10	"github.com/MichaelMure/git-bug/bridge/core"
 11	"github.com/MichaelMure/git-bug/bug"
 12	"github.com/MichaelMure/git-bug/cache"
 13	"github.com/MichaelMure/git-bug/entity"
 14	"github.com/MichaelMure/git-bug/util/text"
 15)
 16
 17const (
 18	keyOrigin      = "origin"
 19	keyGithubId    = "github-id"
 20	keyGithubUrl   = "github-url"
 21	keyGithubLogin = "github-login"
 22)
 23
 24// githubImporter implement the Importer interface
 25type githubImporter struct {
 26	conf core.Configuration
 27
 28	// iterator
 29	iterator *iterator
 30
 31	// send only channel
 32	out chan<- core.ImportResult
 33}
 34
 35func (gi *githubImporter) Init(conf core.Configuration) error {
 36	gi.conf = conf
 37	return nil
 38}
 39
 40// ImportAll iterate over all the configured repository issues and ensure the creation of the
 41// missing issues / timeline items / edits / label events ...
 42func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
 43	gi.iterator = NewIterator(ctx, 10, gi.conf[keyOwner], gi.conf[keyProject], gi.conf[keyToken], since)
 44	out := make(chan core.ImportResult)
 45	gi.out = out
 46
 47	go func() {
 48		defer close(gi.out)
 49
 50		// Loop over all matching issues
 51		for gi.iterator.NextIssue() {
 52			issue := gi.iterator.IssueValue()
 53			// create issue
 54			b, err := gi.ensureIssue(repo, issue)
 55			if err != nil {
 56				err := fmt.Errorf("issue creation: %v", err)
 57				out <- core.NewImportError(err, "")
 58				return
 59			}
 60
 61			// loop over timeline items
 62			for gi.iterator.NextTimelineItem() {
 63				item := gi.iterator.TimelineItemValue()
 64				if err := gi.ensureTimelineItem(repo, b, item); err != nil {
 65					err := fmt.Errorf("timeline item creation: %v", err)
 66					out <- core.NewImportError(err, "")
 67					return
 68				}
 69			}
 70
 71			// commit bug state
 72			if err := b.CommitAsNeeded(); err != nil {
 73				err = fmt.Errorf("bug commit: %v", err)
 74				out <- core.NewImportError(err, "")
 75				return
 76			}
 77		}
 78
 79		if err := gi.iterator.Error(); err != nil && err != context.Canceled {
 80			gi.out <- core.NewImportError(err, "")
 81		}
 82	}()
 83
 84	return out, nil
 85}
 86
 87func (gi *githubImporter) ensureIssue(repo *cache.RepoCache, issue issueTimeline) (*cache.BugCache, error) {
 88	// ensure issue author
 89	author, err := gi.ensurePerson(repo, issue.Author)
 90	if err != nil {
 91		return nil, err
 92	}
 93
 94	// resolve bug
 95	b, err := repo.ResolveBugCreateMetadata(keyGithubUrl, issue.Url.String())
 96	if err != nil && err != bug.ErrBugNotExist {
 97		return nil, err
 98	}
 99
100	// get issue edits
101	var issueEdits []userContentEdit
102	for gi.iterator.NextIssueEdit() {
103		issueEdits = append(issueEdits, gi.iterator.IssueEditValue())
104	}
105
106	// if issueEdits is empty
107	if len(issueEdits) == 0 {
108		if err == bug.ErrBugNotExist {
109			cleanText, err := text.Cleanup(string(issue.Body))
110			if err != nil {
111				return nil, err
112			}
113
114			// create bug
115			b, _, err = repo.NewBugRaw(
116				author,
117				issue.CreatedAt.Unix(),
118				issue.Title,
119				cleanText,
120				nil,
121				map[string]string{
122					keyOrigin:    target,
123					keyGithubId:  parseId(issue.Id),
124					keyGithubUrl: issue.Url.String(),
125				})
126			if err != nil {
127				return nil, err
128			}
129
130			// importing a new bug
131			gi.out <- core.NewImportBug(b.Id())
132		} else {
133			gi.out <- core.NewImportNothing("", "bug already imported")
134		}
135
136	} else {
137		// create bug from given issueEdits
138		for i, edit := range issueEdits {
139			if i == 0 && b != nil {
140				// The first edit in the github result is the issue creation itself, we already have that
141				gi.out <- core.NewImportNothing("", "bug already imported")
142				continue
143			}
144
145			cleanText, err := text.Cleanup(string(*edit.Diff))
146			if err != nil {
147				return nil, err
148			}
149
150			// if the bug doesn't exist
151			if b == nil {
152				// we create the bug as soon as we have a legit first edition
153				b, _, err = repo.NewBugRaw(
154					author,
155					issue.CreatedAt.Unix(),
156					issue.Title,
157					cleanText,
158					nil,
159					map[string]string{
160						keyOrigin:    target,
161						keyGithubId:  parseId(issue.Id),
162						keyGithubUrl: issue.Url.String(),
163					},
164				)
165
166				if err != nil {
167					return nil, err
168				}
169
170				// importing a new bug
171				gi.out <- core.NewImportBug(b.Id())
172				continue
173			}
174
175			// other edits will be added as CommentEdit operations
176			target, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(issue.Id))
177			if err != nil {
178				return nil, err
179			}
180
181			err = gi.ensureCommentEdit(repo, b, target, edit)
182			if err != nil {
183				return nil, err
184			}
185		}
186	}
187
188	return b, nil
189}
190
191func (gi *githubImporter) ensureTimelineItem(repo *cache.RepoCache, b *cache.BugCache, item timelineItem) error {
192
193	switch item.Typename {
194	case "IssueComment":
195		// collect all comment edits
196		var commentEdits []userContentEdit
197		for gi.iterator.NextCommentEdit() {
198			commentEdits = append(commentEdits, gi.iterator.CommentEditValue())
199		}
200
201		// ensureTimelineComment send import events over out chanel
202		err := gi.ensureTimelineComment(repo, b, item.IssueComment, commentEdits)
203		if err != nil {
204			return fmt.Errorf("timeline comment creation: %v", err)
205		}
206
207	case "LabeledEvent":
208		id := parseId(item.LabeledEvent.Id)
209		_, err := b.ResolveOperationWithMetadata(keyGithubId, id)
210		if err == nil {
211			reason := fmt.Sprintf("operation already imported: %v", item.Typename)
212			gi.out <- core.NewImportNothing("", reason)
213			return nil
214		}
215
216		if err != cache.ErrNoMatchingOp {
217			return err
218		}
219		author, err := gi.ensurePerson(repo, item.LabeledEvent.Actor)
220		if err != nil {
221			return err
222		}
223		op, err := b.ForceChangeLabelsRaw(
224			author,
225			item.LabeledEvent.CreatedAt.Unix(),
226			[]string{
227				string(item.LabeledEvent.Label.Name),
228			},
229			nil,
230			map[string]string{keyGithubId: id},
231		)
232		if err != nil {
233			return err
234		}
235
236		gi.out <- core.NewImportLabelChange(op.Id())
237		return nil
238
239	case "UnlabeledEvent":
240		id := parseId(item.UnlabeledEvent.Id)
241		_, err := b.ResolveOperationWithMetadata(keyGithubId, id)
242		if err == nil {
243			reason := fmt.Sprintf("operation already imported: %v", item.Typename)
244			gi.out <- core.NewImportNothing("", reason)
245			return nil
246		}
247		if err != cache.ErrNoMatchingOp {
248			return err
249		}
250		author, err := gi.ensurePerson(repo, item.UnlabeledEvent.Actor)
251		if err != nil {
252			return err
253		}
254
255		op, err := b.ForceChangeLabelsRaw(
256			author,
257			item.UnlabeledEvent.CreatedAt.Unix(),
258			nil,
259			[]string{
260				string(item.UnlabeledEvent.Label.Name),
261			},
262			map[string]string{keyGithubId: id},
263		)
264		if err != nil {
265			return err
266		}
267
268		gi.out <- core.NewImportLabelChange(op.Id())
269		return nil
270
271	case "ClosedEvent":
272		id := parseId(item.ClosedEvent.Id)
273		_, err := b.ResolveOperationWithMetadata(keyGithubId, id)
274		if err != cache.ErrNoMatchingOp {
275			return err
276		}
277		if err == nil {
278			reason := fmt.Sprintf("operation already imported: %v", item.Typename)
279			gi.out <- core.NewImportNothing("", reason)
280			return nil
281		}
282		author, err := gi.ensurePerson(repo, item.ClosedEvent.Actor)
283		if err != nil {
284			return err
285		}
286		op, err := b.CloseRaw(
287			author,
288			item.ClosedEvent.CreatedAt.Unix(),
289			map[string]string{keyGithubId: id},
290		)
291
292		if err != nil {
293			return err
294		}
295
296		gi.out <- core.NewImportStatusChange(op.Id())
297		return nil
298
299	case "ReopenedEvent":
300		id := parseId(item.ReopenedEvent.Id)
301		_, err := b.ResolveOperationWithMetadata(keyGithubId, id)
302		if err != cache.ErrNoMatchingOp {
303			return err
304		}
305		if err == nil {
306			reason := fmt.Sprintf("operation already imported: %v", item.Typename)
307			gi.out <- core.NewImportNothing("", reason)
308			return nil
309		}
310		author, err := gi.ensurePerson(repo, item.ReopenedEvent.Actor)
311		if err != nil {
312			return err
313		}
314		op, err := b.OpenRaw(
315			author,
316			item.ReopenedEvent.CreatedAt.Unix(),
317			map[string]string{keyGithubId: id},
318		)
319
320		if err != nil {
321			return err
322		}
323
324		gi.out <- core.NewImportStatusChange(op.Id())
325		return nil
326
327	case "RenamedTitleEvent":
328		id := parseId(item.RenamedTitleEvent.Id)
329		_, err := b.ResolveOperationWithMetadata(keyGithubId, id)
330		if err != cache.ErrNoMatchingOp {
331			return err
332		}
333		if err == nil {
334			reason := fmt.Sprintf("operation already imported: %v", item.Typename)
335			gi.out <- core.NewImportNothing("", reason)
336			return nil
337		}
338		author, err := gi.ensurePerson(repo, item.RenamedTitleEvent.Actor)
339		if err != nil {
340			return err
341		}
342		op, err := b.SetTitleRaw(
343			author,
344			item.RenamedTitleEvent.CreatedAt.Unix(),
345			string(item.RenamedTitleEvent.CurrentTitle),
346			map[string]string{keyGithubId: id},
347		)
348		if err != nil {
349			return err
350		}
351
352		gi.out <- core.NewImportTitleEdition(op.Id())
353		return nil
354
355	default:
356		reason := fmt.Sprintf("ignoring timeline type: %v", item.Typename)
357		gi.out <- core.NewImportNothing("", reason)
358	}
359
360	return nil
361}
362
363func (gi *githubImporter) ensureTimelineComment(repo *cache.RepoCache, b *cache.BugCache, item issueComment, edits []userContentEdit) error {
364	// ensure person
365	author, err := gi.ensurePerson(repo, item.Author)
366	if err != nil {
367		return err
368	}
369
370	targetOpID, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(item.Id))
371	if err == nil {
372		gi.out <- core.NewImportNothing("", "comment already imported")
373	} else if err != cache.ErrNoMatchingOp {
374		// real error
375		return err
376	}
377
378	// if no edits are given we create the comment
379	if len(edits) == 0 {
380		if err == cache.ErrNoMatchingOp {
381			cleanText, err := text.Cleanup(string(item.Body))
382			if err != nil {
383				return err
384			}
385
386			// add comment operation
387			op, err := b.AddCommentRaw(
388				author,
389				item.CreatedAt.Unix(),
390				cleanText,
391				nil,
392				map[string]string{
393					keyGithubId:  parseId(item.Id),
394					keyGithubUrl: parseId(item.Url.String()),
395				},
396			)
397			if err != nil {
398				return err
399			}
400
401			gi.out <- core.NewImportComment(op.Id())
402		}
403
404	} else {
405		for i, edit := range edits {
406			if i == 0 && targetOpID != "" {
407				// The first edit in the github result is the comment creation itself, we already have that
408				gi.out <- core.NewImportNothing("", "comment already imported")
409				continue
410			}
411
412			// ensure editor identity
413			editor, err := gi.ensurePerson(repo, edit.Editor)
414			if err != nil {
415				return err
416			}
417
418			// create comment when target is empty
419			if targetOpID == "" {
420				cleanText, err := text.Cleanup(string(*edit.Diff))
421				if err != nil {
422					return err
423				}
424
425				op, err := b.AddCommentRaw(
426					editor,
427					edit.CreatedAt.Unix(),
428					cleanText,
429					nil,
430					map[string]string{
431						keyGithubId:  parseId(item.Id),
432						keyGithubUrl: item.Url.String(),
433					},
434				)
435				if err != nil {
436					return err
437				}
438
439				// set target for the nexr edit now that the comment is created
440				targetOpID = op.Id()
441				continue
442			}
443
444			err = gi.ensureCommentEdit(repo, b, targetOpID, edit)
445			if err != nil {
446				return err
447			}
448		}
449	}
450	return nil
451}
452
453func (gi *githubImporter) ensureCommentEdit(repo *cache.RepoCache, b *cache.BugCache, target entity.Id, edit userContentEdit) error {
454	_, err := b.ResolveOperationWithMetadata(keyGithubId, parseId(edit.Id))
455	if err == nil {
456		gi.out <- core.NewImportNothing(b.Id(), "edition already imported")
457		return nil
458	}
459	if err != cache.ErrNoMatchingOp {
460		// real error
461		return err
462	}
463
464	editor, err := gi.ensurePerson(repo, edit.Editor)
465	if err != nil {
466		return err
467	}
468
469	switch {
470	case edit.DeletedAt != nil:
471		// comment deletion, not supported yet
472		gi.out <- core.NewImportNothing(b.Id(), "comment deletion is not supported yet")
473
474	case edit.DeletedAt == nil:
475
476		cleanText, err := text.Cleanup(string(*edit.Diff))
477		if err != nil {
478			return err
479		}
480
481		// comment edition
482		op, err := b.EditCommentRaw(
483			editor,
484			edit.CreatedAt.Unix(),
485			target,
486			cleanText,
487			map[string]string{
488				keyGithubId: parseId(edit.Id),
489			},
490		)
491
492		if err != nil {
493			return err
494		}
495
496		gi.out <- core.NewImportCommentEdition(op.Id())
497	}
498
499	return nil
500}
501
502// ensurePerson create a bug.Person from the Github data
503func (gi *githubImporter) ensurePerson(repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
504	// When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
505	// in it's UI. So we need a special case to get it.
506	if actor == nil {
507		return gi.getGhost(repo)
508	}
509
510	// Look first in the cache
511	i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, string(actor.Login))
512	if err == nil {
513		return i, nil
514	}
515	if _, ok := err.(entity.ErrMultipleMatch); ok {
516		return nil, err
517	}
518
519	// importing a new identity
520
521	var name string
522	var email string
523
524	switch actor.Typename {
525	case "User":
526		if actor.User.Name != nil {
527			name = string(*(actor.User.Name))
528		}
529		email = string(actor.User.Email)
530	case "Organization":
531		if actor.Organization.Name != nil {
532			name = string(*(actor.Organization.Name))
533		}
534		if actor.Organization.Email != nil {
535			email = string(*(actor.Organization.Email))
536		}
537	case "Bot":
538	}
539
540	i, err = repo.NewIdentityRaw(
541		name,
542		email,
543		string(actor.Login),
544		string(actor.AvatarUrl),
545		map[string]string{
546			keyGithubLogin: string(actor.Login),
547		},
548	)
549
550	if err != nil {
551		return nil, err
552	}
553
554	gi.out <- core.NewImportIdentity(i.Id())
555	return i, nil
556}
557
558func (gi *githubImporter) getGhost(repo *cache.RepoCache) (*cache.IdentityCache, error) {
559	// Look first in the cache
560	i, err := repo.ResolveIdentityImmutableMetadata(keyGithubLogin, "ghost")
561	if err == nil {
562		return i, nil
563	}
564	if _, ok := err.(entity.ErrMultipleMatch); ok {
565		return nil, err
566	}
567
568	var q ghostQuery
569
570	variables := map[string]interface{}{
571		"login": githubv4.String("ghost"),
572	}
573
574	gc := buildClient(gi.conf[keyToken])
575
576	ctx, cancel := context.WithTimeout(gi.iterator.ctx, defaultTimeout)
577	defer cancel()
578
579	err = gc.Query(ctx, &q, variables)
580	if err != nil {
581		return nil, err
582	}
583
584	var name string
585	if q.User.Name != nil {
586		name = string(*q.User.Name)
587	}
588
589	return repo.NewIdentityRaw(
590		name,
591		"",
592		string(q.User.Login),
593		string(q.User.AvatarUrl),
594		map[string]string{
595			keyGithubLogin: string(q.User.Login),
596		},
597	)
598}
599
600// parseId convert the unusable githubv4.ID (an interface{}) into a string
601func parseId(id githubv4.ID) string {
602	return fmt.Sprintf("%v", id)
603}