git-bug/bridge/github/import.go

585 lines
15 KiB
Go
Raw Normal View History

2018-09-24 20:22:32 +03:00
package github
import (
"context"
"fmt"
2019-04-27 02:15:02 +03:00
"time"
2018-09-24 20:22:32 +03:00
"github.com/shurcooL/githubv4"
2018-09-24 20:22:32 +03:00
"github.com/MichaelMure/git-bug/bridge/core"
"github.com/MichaelMure/git-bug/bridge/core/auth"
2018-09-25 20:10:38 +03:00
"github.com/MichaelMure/git-bug/bug"
2018-09-24 20:22:32 +03:00
"github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/util/text"
2018-09-24 20:22:32 +03:00
)
2021-04-09 14:07:45 +03:00
const EmptyTitlePlaceholder = "<empty string>"
2021-03-05 22:06:21 +03:00
2018-09-25 20:10:38 +03:00
// githubImporter implement the Importer interface
type githubImporter struct {
conf core.Configuration
2019-05-10 03:05:00 +03:00
// default client
client *githubv4.Client
// mediator to access the Github API
mediator *importMediator
2019-05-10 03:05:00 +03:00
// send only channel
out chan<- core.ImportResult
}
func (gi *githubImporter) Init(_ context.Context, repo *cache.RepoCache, conf core.Configuration) error {
gi.conf = conf
creds, err := auth.List(repo,
auth.WithTarget(target),
auth.WithKind(auth.KindToken),
auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
)
if err != nil {
return err
}
if len(creds) <= 0 {
return ErrMissingIdentityToken
}
gi.client = buildClient(creds[0].(*auth.Token))
return nil
}
// ImportAll iterate over all the configured repository issues and ensure the creation of the
// missing issues / timeline items / edits / label events ...
func (gi *githubImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
gi.mediator = NewImportMediator(ctx, gi.client, gi.conf[confKeyOwner], gi.conf[confKeyProject], since)
out := make(chan core.ImportResult)
gi.out = out
go func() {
defer close(gi.out)
2021-03-22 21:26:59 +03:00
var currBug *cache.BugCache
var currEvent ImportEvent
var nextEvent ImportEvent
var err error
for {
// An IssueEvent contains the issue in its most recent state. If an issue
// has at least one issue edit, then the history of the issue edits is
// represented by IssueEditEvents. That is, the unedited (original) issue
// might be saved only in the IssueEditEvent following the IssueEvent.
// Since we replicate the edit history we need to either use the IssueEvent
// (if there are no edits) or the IssueEvent together with its first
// IssueEditEvent (if there are edits).
// Exactly the same is true for comments and comment edits.
// As a consequence we need to look at the current event and one look ahead
// event.
2021-03-22 21:26:59 +03:00
currEvent = nextEvent
if currEvent == nil {
currEvent = gi.getEventHandleMsgs()
}
2021-03-22 21:26:59 +03:00
if currEvent == nil {
break
}
nextEvent = gi.getEventHandleMsgs()
2021-03-22 21:26:59 +03:00
switch event := currEvent.(type) {
case RateLimitingEvent:
out <- core.NewImportRateLimiting(event.msg)
2021-03-22 21:26:59 +03:00
case IssueEvent:
// first: commit what is being held in currBug
if err = gi.commit(currBug, out); err != nil {
out <- core.NewImportError(err, "")
return
}
// second: create new issue
switch next := nextEvent.(type) {
case IssueEditEvent:
// consuming and using next event
nextEvent = nil
currBug, err = gi.ensureIssue(ctx, repo, &event.issue, &next.userContentEdit)
default:
2021-03-22 21:26:59 +03:00
currBug, err = gi.ensureIssue(ctx, repo, &event.issue, nil)
}
if err != nil {
err := fmt.Errorf("issue creation: %v", err)
out <- core.NewImportError(err, "")
return
}
case IssueEditEvent:
err = gi.ensureIssueEdit(ctx, repo, currBug, event.issueId, &event.userContentEdit)
if err != nil {
err = fmt.Errorf("issue edit: %v", err)
out <- core.NewImportError(err, "")
return
}
case TimelineEvent:
if next, ok := nextEvent.(CommentEditEvent); ok && event.Typename == "IssueComment" {
// consuming and using next event
nextEvent = nil
err = gi.ensureComment(ctx, repo, currBug, &event.timelineItem.IssueComment, &next.userContentEdit)
} else {
err = gi.ensureTimelineItem(ctx, repo, currBug, &event.timelineItem)
}
if err != nil {
err = fmt.Errorf("timeline item creation: %v", err)
out <- core.NewImportError(err, "")
return
}
2021-03-22 21:26:59 +03:00
case CommentEditEvent:
err = gi.ensureCommentEdit(ctx, repo, currBug, event.commentId, &event.userContentEdit)
if err != nil {
err = fmt.Errorf("comment edit: %v", err)
out <- core.NewImportError(err, "")
return
}
default:
panic("Unknown event type")
}
}
2021-03-22 21:26:59 +03:00
// commit what is being held in currBug before returning
if err = gi.commit(currBug, out); err != nil {
out <- core.NewImportError(err, "")
}
if err = gi.mediator.Error(); err != nil {
gi.out <- core.NewImportError(err, "")
2019-04-27 02:15:02 +03:00
}
}()
return out, nil
2018-09-25 20:10:38 +03:00
}
func (gi *githubImporter) getEventHandleMsgs() ImportEvent {
for {
// read event from import mediator
event := gi.mediator.NextImportEvent()
// consume (and use) all rate limiting events
if e, ok := event.(RateLimitingEvent); ok {
gi.out <- core.NewImportRateLimiting(e.msg)
continue
}
return event
}
}
2021-03-22 21:26:59 +03:00
func (gi *githubImporter) commit(b *cache.BugCache, out chan<- core.ImportResult) error {
if b == nil {
return nil
}
if !b.NeedCommit() {
out <- core.NewImportNothing(b.Id(), "no imported operation")
return nil
} else if err := b.Commit(); err != nil {
// commit bug state
return fmt.Errorf("bug commit: %v", err)
}
2021-03-22 21:26:59 +03:00
return nil
}
2021-03-22 21:26:59 +03:00
func (gi *githubImporter) ensureIssue(ctx context.Context, repo *cache.RepoCache, issue *issue, issueEdit *userContentEdit) (*cache.BugCache, error) {
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, issue.Author)
if err != nil {
return nil, err
}
// resolve bug
2020-03-28 19:08:27 +03:00
b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
excerpt.CreateMetadata[metaKeyGithubId] == parseId(issue.Id)
})
2021-03-22 21:26:59 +03:00
if err == nil {
return b, nil
}
if err != bug.ErrBugNotExist {
return nil, err
}
2021-03-05 22:06:21 +03:00
// At Github there exist issues with seemingly empty titles. An example is
// https://github.com/NixOS/nixpkgs/issues/72730 .
// The title provided by the GraphQL API actually consists of a space followed by a
// zero width space (U+200B). This title would cause the NewBugRaw() function to
// return an error: empty title.
title := string(issue.Title)
2021-03-05 22:06:21 +03:00
if title == " \u200b" { // U+200B == zero width space
2021-04-09 14:07:45 +03:00
title = EmptyTitlePlaceholder
2019-05-10 13:02:44 +03:00
}
2021-03-22 21:26:59 +03:00
var textInput string
if issueEdit != nil {
// use the first issue edit: it represents the bug creation itself
textInput = string(*issueEdit.Diff)
} else {
// if there are no issue edits then the issue struct holds the bug creation
textInput = string(issue.Body)
}
2021-03-22 21:26:59 +03:00
// create bug
b, _, err = repo.NewBugRaw(
author,
issue.CreatedAt.Unix(),
text.CleanupOneLine(title), // TODO: this is the *current* title, not the original one
text.Cleanup(textInput),
2021-03-22 21:26:59 +03:00
nil,
map[string]string{
core.MetaKeyOrigin: target,
metaKeyGithubId: parseId(issue.Id),
metaKeyGithubUrl: issue.Url.String(),
})
if err != nil {
return nil, err
}
2021-03-22 21:26:59 +03:00
// importing a new bug
gi.out <- core.NewImportBug(b.Id())
return b, nil
2019-04-27 02:15:02 +03:00
}
2021-03-22 21:26:59 +03:00
func (gi *githubImporter) ensureIssueEdit(ctx context.Context, repo *cache.RepoCache, bug *cache.BugCache, ghIssueId githubv4.ID, edit *userContentEdit) error {
return gi.ensureCommentEdit(ctx, repo, bug, ghIssueId, edit)
}
func (gi *githubImporter) ensureTimelineItem(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, item *timelineItem) error {
2018-09-25 20:10:38 +03:00
switch item.Typename {
case "IssueComment":
2021-03-22 21:26:59 +03:00
err := gi.ensureComment(ctx, repo, b, &item.IssueComment, nil)
2019-05-10 03:05:00 +03:00
if err != nil {
return fmt.Errorf("timeline comment creation: %v", err)
}
return nil
2018-09-25 20:10:38 +03:00
case "LabeledEvent":
id := parseId(item.LabeledEvent.Id)
_, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
if err == nil {
return nil
}
if err != cache.ErrNoMatchingOp {
return err
}
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, item.LabeledEvent.Actor)
2019-01-19 18:01:06 +03:00
if err != nil {
return err
}
op, err := b.ForceChangeLabelsRaw(
2019-01-19 18:01:06 +03:00
author,
2018-09-25 20:10:38 +03:00
item.LabeledEvent.CreatedAt.Unix(),
[]string{
text.CleanupOneLine(string(item.LabeledEvent.Label.Name)),
2018-09-25 20:10:38 +03:00
},
nil,
map[string]string{metaKeyGithubId: id},
2018-09-25 20:10:38 +03:00
)
if err != nil {
return err
}
2019-05-04 14:19:56 +03:00
gi.out <- core.NewImportLabelChange(op.Id())
return nil
2018-09-25 20:10:38 +03:00
case "UnlabeledEvent":
id := parseId(item.UnlabeledEvent.Id)
_, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
if err == nil {
return nil
}
if err != cache.ErrNoMatchingOp {
return err
}
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, item.UnlabeledEvent.Actor)
2019-01-19 18:01:06 +03:00
if err != nil {
return err
}
op, err := b.ForceChangeLabelsRaw(
2019-01-19 18:01:06 +03:00
author,
2018-09-25 20:10:38 +03:00
item.UnlabeledEvent.CreatedAt.Unix(),
nil,
[]string{
text.CleanupOneLine(string(item.UnlabeledEvent.Label.Name)),
2018-09-25 20:10:38 +03:00
},
map[string]string{metaKeyGithubId: id},
2018-09-25 20:10:38 +03:00
)
if err != nil {
return err
}
gi.out <- core.NewImportLabelChange(op.Id())
return nil
2018-09-25 20:10:38 +03:00
case "ClosedEvent":
id := parseId(item.ClosedEvent.Id)
_, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
if err != cache.ErrNoMatchingOp {
return err
}
if err == nil {
return nil
}
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, item.ClosedEvent.Actor)
2019-01-19 18:01:06 +03:00
if err != nil {
return err
}
op, err := b.CloseRaw(
2019-01-19 18:01:06 +03:00
author,
2018-09-25 20:10:38 +03:00
item.ClosedEvent.CreatedAt.Unix(),
map[string]string{metaKeyGithubId: id},
2018-09-25 20:10:38 +03:00
)
if err != nil {
return err
}
gi.out <- core.NewImportStatusChange(op.Id())
return nil
2018-09-25 20:10:38 +03:00
case "ReopenedEvent":
id := parseId(item.ReopenedEvent.Id)
_, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
if err != cache.ErrNoMatchingOp {
return err
}
if err == nil {
return nil
}
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, item.ReopenedEvent.Actor)
2019-01-19 18:01:06 +03:00
if err != nil {
return err
}
op, err := b.OpenRaw(
2019-01-19 18:01:06 +03:00
author,
2018-09-25 20:10:38 +03:00
item.ReopenedEvent.CreatedAt.Unix(),
map[string]string{metaKeyGithubId: id},
2018-09-25 20:10:38 +03:00
)
if err != nil {
return err
}
gi.out <- core.NewImportStatusChange(op.Id())
return nil
2018-09-25 20:10:38 +03:00
case "RenamedTitleEvent":
id := parseId(item.RenamedTitleEvent.Id)
_, err := b.ResolveOperationWithMetadata(metaKeyGithubId, id)
if err != cache.ErrNoMatchingOp {
return err
}
if err == nil {
return nil
}
2021-03-05 22:06:21 +03:00
author, err := gi.ensurePerson(ctx, repo, item.RenamedTitleEvent.Actor)
2019-01-19 18:01:06 +03:00
if err != nil {
return err
}
2021-03-05 22:06:21 +03:00
// At Github there exist issues with seemingly empty titles. An example is
// https://github.com/NixOS/nixpkgs/issues/72730 .
// The title provided by the GraphQL API actually consists of a space followed
// by a zero width space (U+200B). This title would cause the NewBugRaw()
// function to return an error: empty title.
title := text.CleanupOneLine(string(item.RenamedTitleEvent.CurrentTitle))
2021-03-05 22:06:21 +03:00
if title == " \u200b" { // U+200B == zero width space
2021-04-09 14:07:45 +03:00
title = EmptyTitlePlaceholder
2021-03-05 22:06:21 +03:00
}
op, err := b.SetTitleRaw(
2019-01-19 18:01:06 +03:00
author,
2018-09-25 20:10:38 +03:00
item.RenamedTitleEvent.CreatedAt.Unix(),
2021-03-05 22:06:21 +03:00
title,
map[string]string{metaKeyGithubId: id},
2018-09-25 20:10:38 +03:00
)
if err != nil {
return err
}
gi.out <- core.NewImportTitleEdition(op.Id())
return nil
2018-09-25 20:10:38 +03:00
}
return nil
}
2021-03-22 21:26:59 +03:00
func (gi *githubImporter) ensureCommentEdit(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, ghTargetId githubv4.ID, edit *userContentEdit) error {
// find comment
target, err := b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(ghTargetId))
if err != nil {
return err
}
2021-03-22 21:26:59 +03:00
_, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(edit.Id))
if err == nil {
return nil
}
if err != cache.ErrNoMatchingOp {
// real error
return err
}
2021-03-22 21:26:59 +03:00
editor, err := gi.ensurePerson(ctx, repo, edit.Editor)
if err != nil {
return err
}
2021-03-22 21:26:59 +03:00
if edit.DeletedAt != nil {
// comment deletion, not supported yet
return nil
}
2021-03-22 21:26:59 +03:00
// comment edition
op, err := b.EditCommentRaw(
editor,
edit.CreatedAt.Unix(),
target,
text.Cleanup(string(*edit.Diff)),
2021-03-22 21:26:59 +03:00
map[string]string{
metaKeyGithubId: parseId(edit.Id),
},
)
if err != nil {
return err
}
2021-03-22 21:26:59 +03:00
gi.out <- core.NewImportCommentEdition(op.Id())
return nil
}
2021-03-22 21:26:59 +03:00
func (gi *githubImporter) ensureComment(ctx context.Context, repo *cache.RepoCache, b *cache.BugCache, comment *issueComment, firstEdit *userContentEdit) error {
author, err := gi.ensurePerson(ctx, repo, comment.Author)
if err != nil {
return err
}
_, err = b.ResolveOperationWithMetadata(metaKeyGithubId, parseId(comment.Id))
if err == nil {
return nil
}
if err != cache.ErrNoMatchingOp {
// real error
return err
}
2021-03-22 21:26:59 +03:00
var textInput string
if firstEdit != nil {
// use the first comment edit: it represents the comment creation itself
textInput = string(*firstEdit.Diff)
} else {
// if there are not comment edits, then the comment struct holds the comment creation
textInput = string(comment.Body)
}
2019-01-19 18:01:06 +03:00
2021-03-22 21:26:59 +03:00
// add comment operation
op, err := b.AddCommentRaw(
author,
comment.CreatedAt.Unix(),
text.Cleanup(textInput),
2021-03-22 21:26:59 +03:00
nil,
map[string]string{
metaKeyGithubId: parseId(comment.Id),
metaKeyGithubUrl: comment.Url.String(),
},
)
if err != nil {
return err
}
2021-03-22 21:26:59 +03:00
gi.out <- core.NewImportComment(op.Id())
return nil
}
2019-02-24 14:58:04 +03:00
// ensurePerson create a bug.Person from the Github data
2021-03-05 22:06:21 +03:00
func (gi *githubImporter) ensurePerson(ctx context.Context, repo *cache.RepoCache, actor *actor) (*cache.IdentityCache, error) {
2019-01-19 18:01:06 +03:00
// When a user has been deleted, Github return a null actor, while displaying a profile named "ghost"
// in it's UI. So we need a special case to get it.
if actor == nil {
2021-03-05 22:06:21 +03:00
return gi.getGhost(ctx, repo)
2019-01-19 18:01:06 +03:00
}
// Look first in the cache
i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, string(actor.Login))
2019-01-19 18:01:06 +03:00
if err == nil {
return i, nil
}
if entity.IsErrMultipleMatch(err) {
2019-01-19 18:01:06 +03:00
return nil, err
}
2019-01-19 18:01:06 +03:00
2019-05-10 03:05:00 +03:00
// importing a new identity
2018-10-07 19:27:23 +03:00
var name string
var email string
switch actor.Typename {
case "User":
if actor.User.Name != nil {
name = string(*(actor.User.Name))
}
email = string(actor.User.Email)
case "Organization":
if actor.Organization.Name != nil {
name = string(*(actor.Organization.Name))
}
if actor.Organization.Email != nil {
email = string(*(actor.Organization.Email))
}
case "Bot":
}
2020-02-09 00:04:25 +03:00
// Name is not necessarily set, fallback to login as a name is required in the identity
if name == "" {
name = string(actor.Login)
}
i, err = repo.NewIdentityRaw(
2019-01-19 18:01:06 +03:00
name,
email,
string(actor.Login),
2019-01-19 18:01:06 +03:00
string(actor.AvatarUrl),
2020-11-08 21:18:44 +03:00
nil,
2019-01-19 18:01:06 +03:00
map[string]string{
metaKeyGithubLogin: string(actor.Login),
2019-01-19 18:01:06 +03:00
},
)
if err != nil {
return nil, err
}
gi.out <- core.NewImportIdentity(i.Id())
return i, nil
2018-09-25 20:10:38 +03:00
}
2021-03-05 22:06:21 +03:00
func (gi *githubImporter) getGhost(ctx context.Context, repo *cache.RepoCache) (*cache.IdentityCache, error) {
loginName := "ghost"
2019-01-19 18:01:06 +03:00
// Look first in the cache
i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGithubLogin, loginName)
2019-01-19 18:01:06 +03:00
if err == nil {
return i, nil
}
if entity.IsErrMultipleMatch(err) {
2019-01-19 18:01:06 +03:00
return nil, err
}
2021-03-05 22:06:21 +03:00
user, err := gi.mediator.User(ctx, loginName)
2021-04-09 14:07:45 +03:00
if err != nil {
return nil, err
}
userName := ""
if user.Name != nil {
userName = string(*user.Name)
}
2019-01-19 18:01:06 +03:00
return repo.NewIdentityRaw(
userName,
"",
string(user.Login),
string(user.AvatarUrl),
2020-11-08 21:18:44 +03:00
nil,
2019-01-19 18:01:06 +03:00
map[string]string{
metaKeyGithubLogin: string(user.Login),
2019-01-19 18:01:06 +03:00
},
2019-01-17 05:09:08 +03:00
)
}
2021-03-05 22:06:21 +03:00
// parseId converts the unusable githubv4.ID (an interface{}) into a string
2018-09-25 20:10:38 +03:00
func parseId(id githubv4.ID) string {
return fmt.Sprintf("%v", id)
}