git-bug/bridge/gitlab/import.go
Matthias Simon aa4e225a80 gitlab: Add new iterator with state change events
Retrieving events is spread across various various Gitlab APIs. This
makes importing and sorting Gitlab events by time quite complicated.

This commit replaces the old iterators with a goroutine/channel-based
iterator, which merges the individual Gitlab API streams into a single
(sorted) event stream.
2021-04-23 09:02:48 +02:00

368 lines
8.0 KiB
Go

package gitlab
import (
"context"
"fmt"
"strconv"
"time"
"github.com/xanzy/go-gitlab"
"github.com/MichaelMure/git-bug/bridge/core"
"github.com/MichaelMure/git-bug/bridge/core/auth"
"github.com/MichaelMure/git-bug/bug"
"github.com/MichaelMure/git-bug/cache"
"github.com/MichaelMure/git-bug/entity"
"github.com/MichaelMure/git-bug/util/text"
)
// gitlabImporter implement the Importer interface
type gitlabImporter struct {
conf core.Configuration
// default client
client *gitlab.Client
// send only channel
out chan<- core.ImportResult
}
func (gi *gitlabImporter) 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.MetaKeyBaseURL, conf[confKeyGitlabBaseUrl]),
auth.WithMeta(auth.MetaKeyLogin, conf[confKeyDefaultLogin]),
)
if err != nil {
return err
}
if len(creds) == 0 {
return ErrMissingIdentityToken
}
gi.client, err = buildClient(conf[confKeyGitlabBaseUrl], creds[0].(*auth.Token))
if err != nil {
return err
}
return nil
}
// ImportAll iterate over all the configured repository issues (notes) and ensure the creation
// of the missing issues / comments / label events / title changes ...
func (gi *gitlabImporter) ImportAll(ctx context.Context, repo *cache.RepoCache, since time.Time) (<-chan core.ImportResult, error) {
out := make(chan core.ImportResult)
gi.out = out
go func() {
defer close(out)
for issue := range Issues(ctx, gi.client, gi.conf[confKeyProjectID], since) {
b, err := gi.ensureIssue(repo, issue)
if err != nil {
err := fmt.Errorf("issue creation: %v", err)
out <- core.NewImportError(err, "")
return
}
for _, e := range SortedEvents(IssueEvents(ctx, gi.client, issue)) {
if e, ok := e.(ErrorEvent); ok {
out <- core.NewImportError(e.Err, "")
continue
}
if err := gi.ensureIssueEvent(repo, b, issue, e); err != nil {
err := fmt.Errorf("issue event creation: %v", err)
out <- core.NewImportError(err, entity.Id(e.ID()))
}
}
if !b.NeedCommit() {
out <- core.NewImportNothing(b.Id(), "no imported operation")
} else if err := b.Commit(); err != nil {
// commit bug state
err := fmt.Errorf("bug commit: %v", err)
out <- core.NewImportError(err, "")
return
}
}
}()
return out, nil
}
func (gi *gitlabImporter) ensureIssue(repo *cache.RepoCache, issue *gitlab.Issue) (*cache.BugCache, error) {
// ensure issue author
author, err := gi.ensurePerson(repo, issue.Author.ID)
if err != nil {
return nil, err
}
// resolve bug
b, err := repo.ResolveBugMatcher(func(excerpt *cache.BugExcerpt) bool {
return excerpt.CreateMetadata[core.MetaKeyOrigin] == target &&
excerpt.CreateMetadata[metaKeyGitlabId] == fmt.Sprintf("%d", issue.IID) &&
excerpt.CreateMetadata[metaKeyGitlabBaseUrl] == gi.conf[confKeyGitlabBaseUrl] &&
excerpt.CreateMetadata[metaKeyGitlabProject] == gi.conf[confKeyProjectID]
})
if err == nil {
return b, nil
}
if err != bug.ErrBugNotExist {
return nil, err
}
// if bug was never imported, create bug
b, _, err = repo.NewBugRaw(
author,
issue.CreatedAt.Unix(),
text.CleanupOneLine(issue.Title),
text.Cleanup(issue.Description),
nil,
map[string]string{
core.MetaKeyOrigin: target,
metaKeyGitlabId: fmt.Sprintf("%d", issue.IID),
metaKeyGitlabUrl: issue.WebURL,
metaKeyGitlabProject: gi.conf[confKeyProjectID],
metaKeyGitlabBaseUrl: gi.conf[confKeyGitlabBaseUrl],
},
)
if err != nil {
return nil, err
}
// importing a new bug
gi.out <- core.NewImportBug(b.Id())
return b, nil
}
func (gi *gitlabImporter) ensureIssueEvent(repo *cache.RepoCache, b *cache.BugCache, issue *gitlab.Issue, event Event) error {
id, errResolve := b.ResolveOperationWithMetadata(metaKeyGitlabId, event.ID())
if errResolve != nil && errResolve != cache.ErrNoMatchingOp {
return errResolve
}
// ensure issue author
author, err := gi.ensurePerson(repo, event.UserID())
if err != nil {
return err
}
switch event.Kind() {
case EventClosed:
if errResolve == nil {
return nil
}
op, err := b.CloseRaw(
author,
event.CreatedAt().Unix(),
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportStatusChange(op.Id())
case EventReopened:
if errResolve == nil {
return nil
}
op, err := b.OpenRaw(
author,
event.CreatedAt().Unix(),
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportStatusChange(op.Id())
case EventDescriptionChanged:
firstComment := b.Snapshot().Comments[0]
// since gitlab doesn't provide the issue history
// we should check for "changed the description" notes and compare issue texts
// TODO: Check only one time and ignore next 'description change' within one issue
if errResolve == cache.ErrNoMatchingOp && issue.Description != firstComment.Message {
// comment edition
op, err := b.EditCommentRaw(
author,
event.(NoteEvent).UpdatedAt.Unix(),
firstComment.Id(),
text.Cleanup(issue.Description),
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportTitleEdition(op.Id())
}
case EventComment:
cleanText := text.Cleanup(event.(NoteEvent).Body)
// if we didn't import the comment
if errResolve == cache.ErrNoMatchingOp {
// add comment operation
op, err := b.AddCommentRaw(
author,
event.CreatedAt().Unix(),
cleanText,
nil,
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportComment(op.Id())
return nil
}
// if comment was already exported
// search for last comment update
comment, err := b.Snapshot().SearchComment(id)
if err != nil {
return err
}
// compare local bug comment with the new event body
if comment.Message != cleanText {
// comment edition
op, err := b.EditCommentRaw(
author,
event.(NoteEvent).UpdatedAt.Unix(),
comment.Id(),
cleanText,
nil,
)
if err != nil {
return err
}
gi.out <- core.NewImportCommentEdition(op.Id())
}
return nil
case EventTitleChanged:
// title change events are given new notes
if errResolve == nil {
return nil
}
op, err := b.SetTitleRaw(
author,
event.CreatedAt().Unix(),
event.(NoteEvent).Title(),
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
if err != nil {
return err
}
gi.out <- core.NewImportTitleEdition(op.Id())
case EventAddLabel:
_, err = b.ForceChangeLabelsRaw(
author,
event.CreatedAt().Unix(),
[]string{event.(LabelEvent).Label.Name},
nil,
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
return err
case EventRemoveLabel:
_, err = b.ForceChangeLabelsRaw(
author,
event.CreatedAt().Unix(),
nil,
[]string{event.(LabelEvent).Label.Name},
map[string]string{
metaKeyGitlabId: event.ID(),
},
)
return err
case EventAssigned,
EventUnassigned,
EventChangedMilestone,
EventRemovedMilestone,
EventChangedDuedate,
EventRemovedDuedate,
EventLocked,
EventUnlocked,
EventMentionedInIssue,
EventMentionedInMergeRequest:
return nil
default:
return fmt.Errorf("unexpected event")
}
return nil
}
func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) {
// Look first in the cache
i, err := repo.ResolveIdentityImmutableMetadata(metaKeyGitlabId, strconv.Itoa(id))
if err == nil {
return i, nil
}
if entity.IsErrMultipleMatch(err) {
return nil, err
}
user, _, err := gi.client.Users.GetUser(id)
if err != nil {
return nil, err
}
i, err = repo.NewIdentityRaw(
user.Name,
user.PublicEmail,
user.Username,
user.AvatarURL,
nil,
map[string]string{
// because Gitlab
metaKeyGitlabId: strconv.Itoa(id),
metaKeyGitlabLogin: user.Username,
},
)
if err != nil {
return nil, err
}
gi.out <- core.NewImportIdentity(i.Id())
return i, nil
}