diff options
author | Michael Muré <batolettre@gmail.com> | 2019-07-23 19:50:58 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-07-23 19:50:58 +0200 |
commit | 9ecbcb1cf6348b95b31ccef3f9722be078dbe223 (patch) | |
tree | d855b993905051d5ff5dbc3e30460bc09fa2e2c4 /bridge/gitlab | |
parent | ca00c9c6b84f0b1333e40666ab979d0d8fdc4036 (diff) | |
parent | 29fdd37ce69b48aa9fc3c1b829ff67818041068f (diff) | |
download | git-bug-9ecbcb1cf6348b95b31ccef3f9722be078dbe223.tar.gz |
Merge pull request #179 from MichaelMure/gitlab-support
Add gitlab bridge configuration and importer
Diffstat (limited to 'bridge/gitlab')
-rw-r--r-- | bridge/gitlab/config.go | 218 | ||||
-rw-r--r-- | bridge/gitlab/config_test.go | 90 | ||||
-rw-r--r-- | bridge/gitlab/export.go | 27 | ||||
-rw-r--r-- | bridge/gitlab/export_test.go | 1 | ||||
-rw-r--r-- | bridge/gitlab/gitlab.go | 50 | ||||
-rw-r--r-- | bridge/gitlab/import.go | 338 | ||||
-rw-r--r-- | bridge/gitlab/import_notes.go | 98 | ||||
-rw-r--r-- | bridge/gitlab/import_notes_test.go | 56 | ||||
-rw-r--r-- | bridge/gitlab/import_test.go | 151 | ||||
-rw-r--r-- | bridge/gitlab/iterator.go | 242 |
10 files changed, 1271 insertions, 0 deletions
diff --git a/bridge/gitlab/config.go b/bridge/gitlab/config.go new file mode 100644 index 00000000..15172871 --- /dev/null +++ b/bridge/gitlab/config.go @@ -0,0 +1,218 @@ +package gitlab + +import ( + "bufio" + "fmt" + "net/url" + "os" + "regexp" + "strconv" + "strings" + + "github.com/pkg/errors" + "github.com/xanzy/go-gitlab" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/repository" +) + +var ( + ErrBadProjectURL = errors.New("bad project url") +) + +func (*Gitlab) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) { + if params.Project != "" { + fmt.Println("warning: --project is ineffective for a gitlab bridge") + } + if params.Owner != "" { + fmt.Println("warning: --owner is ineffective for a gitlab bridge") + } + + conf := make(core.Configuration) + var err error + var url string + var token string + + // get project url + if params.URL != "" { + url = params.URL + + } else { + // remote suggestions + remotes, err := repo.GetRemotes() + if err != nil { + return nil, errors.Wrap(err, "getting remotes") + } + + // terminal prompt + url, err = promptURL(remotes) + if err != nil { + return nil, errors.Wrap(err, "url prompt") + } + } + + // get user token + if params.Token != "" { + token = params.Token + } else { + token, err = promptToken() + if err != nil { + return nil, errors.Wrap(err, "token prompt") + } + } + + // validate project url and get its ID + id, err := validateProjectURL(url, token) + if err != nil { + return nil, errors.Wrap(err, "project validation") + } + + conf[keyProjectID] = strconv.Itoa(id) + conf[keyToken] = token + conf[core.KeyTarget] = target + + return conf, nil +} + +func (*Gitlab) ValidateConfig(conf core.Configuration) error { + if v, ok := conf[core.KeyTarget]; !ok { + return fmt.Errorf("missing %s key", core.KeyTarget) + } else if v != target { + return fmt.Errorf("unexpected target name: %v", v) + } + + if _, ok := conf[keyToken]; !ok { + return fmt.Errorf("missing %s key", keyToken) + } + + if _, ok := conf[keyProjectID]; !ok { + return fmt.Errorf("missing %s key", keyProjectID) + } + + return nil +} + +func promptToken() (string, error) { + fmt.Println("You can generate a new token by visiting https://gitlab.com/profile/personal_access_tokens.") + fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.") + fmt.Println() + fmt.Println("'api' access scope: to be able to make api calls") + fmt.Println() + + re, err := regexp.Compile(`^[a-zA-Z0-9\-]{20}`) + if err != nil { + panic("regexp compile:" + err.Error()) + } + + for { + fmt.Print("Enter token: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + token := strings.TrimRight(line, "\n") + if re.MatchString(token) { + return token, nil + } + + fmt.Println("token format is invalid") + } +} + +func promptURL(remotes map[string]string) (string, error) { + validRemotes := getValidGitlabRemoteURLs(remotes) + if len(validRemotes) > 0 { + for { + fmt.Println("\nDetected projects:") + + // print valid remote gitlab urls + for i, remote := range validRemotes { + fmt.Printf("[%d]: %v\n", i+1, remote) + } + + fmt.Printf("\n[0]: Another project\n\n") + fmt.Printf("Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || index < 0 || index > len(validRemotes) { + fmt.Println("invalid input") + continue + } + + // if user want to enter another project url break this loop + if index == 0 { + break + } + + return validRemotes[index-1], nil + } + } + + // manually enter gitlab url + for { + fmt.Print("Gitlab project URL: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + url := strings.TrimRight(line, "\n") + if line == "" { + fmt.Println("URL is empty") + continue + } + + return url, nil + } +} + +func getProjectPath(projectUrl string) (string, error) { + cleanUrl := strings.TrimSuffix(projectUrl, ".git") + cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1) + objectUrl, err := url.Parse(cleanUrl) + if err != nil { + return "", ErrBadProjectURL + } + + return objectUrl.Path[1:], nil +} + +func getValidGitlabRemoteURLs(remotes map[string]string) []string { + urls := make([]string, 0, len(remotes)) + for _, u := range remotes { + path, err := getProjectPath(u) + if err != nil { + continue + } + + urls = append(urls, fmt.Sprintf("%s%s", "gitlab.com", path)) + } + + return urls +} + +func validateProjectURL(url, token string) (int, error) { + projectPath, err := getProjectPath(url) + if err != nil { + return 0, err + } + + client := buildClient(token) + + project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{}) + if err != nil { + return 0, err + } + + return project.ID, nil +} diff --git a/bridge/gitlab/config_test.go b/bridge/gitlab/config_test.go new file mode 100644 index 00000000..87469796 --- /dev/null +++ b/bridge/gitlab/config_test.go @@ -0,0 +1,90 @@ +package gitlab + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectPath(t *testing.T) { + type args struct { + url string + } + type want struct { + path string + err error + } + tests := []struct { + name string + args args + want want + }{ + { + name: "default url", + args: args{ + url: "https://gitlab.com/MichaelMure/git-bug", + }, + want: want{ + path: "MichaelMure/git-bug", + err: nil, + }, + }, + { + name: "multiple sub groups", + args: args{ + url: "https://gitlab.com/MichaelMure/group/subgroup/git-bug", + }, + want: want{ + path: "MichaelMure/group/subgroup/git-bug", + err: nil, + }, + }, + { + name: "default url with git extension", + args: args{ + url: "https://gitlab.com/MichaelMure/git-bug.git", + }, + want: want{ + path: "MichaelMure/git-bug", + err: nil, + }, + }, + { + name: "url with git protocol", + args: args{ + url: "git://gitlab.com/MichaelMure/git-bug.git", + }, + want: want{ + path: "MichaelMure/git-bug", + err: nil, + }, + }, + { + name: "ssh url", + args: args{ + url: "git@gitlab.com/MichaelMure/git-bug.git", + }, + want: want{ + path: "MichaelMure/git-bug", + err: nil, + }, + }, + { + name: "bad url", + args: args{ + url: "---,%gitlab.com/MichaelMure/git-bug.git", + }, + want: want{ + err: ErrBadProjectURL, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := getProjectPath(tt.args.url) + assert.Equal(t, tt.want.path, path) + assert.Equal(t, tt.want.err, err) + }) + } +} diff --git a/bridge/gitlab/export.go b/bridge/gitlab/export.go new file mode 100644 index 00000000..85b5ee2e --- /dev/null +++ b/bridge/gitlab/export.go @@ -0,0 +1,27 @@ +package gitlab + +import ( + "time" + + "github.com/pkg/errors" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/cache" +) + +var ( + ErrMissingIdentityToken = errors.New("missing identity token") +) + +// gitlabExporter implement the Exporter interface +type gitlabExporter struct{} + +// Init . +func (ge *gitlabExporter) Init(conf core.Configuration) error { + return nil +} + +// ExportAll export all event made by the current user to Gitlab +func (ge *gitlabExporter) ExportAll(repo *cache.RepoCache, since time.Time) (<-chan core.ExportResult, error) { + return nil, nil +} diff --git a/bridge/gitlab/export_test.go b/bridge/gitlab/export_test.go new file mode 100644 index 00000000..4d4b35af --- /dev/null +++ b/bridge/gitlab/export_test.go @@ -0,0 +1 @@ +package gitlab diff --git a/bridge/gitlab/gitlab.go b/bridge/gitlab/gitlab.go new file mode 100644 index 00000000..63c212ed --- /dev/null +++ b/bridge/gitlab/gitlab.go @@ -0,0 +1,50 @@ +package gitlab + +import ( + "net/http" + "time" + + "github.com/xanzy/go-gitlab" + + "github.com/MichaelMure/git-bug/bridge/core" +) + +const ( + target = "gitlab" + + keyGitlabId = "gitlab-id" + keyGitlabUrl = "gitlab-url" + keyGitlabLogin = "gitlab-login" + keyGitlabProject = "gitlab-project-id" + + keyProjectID = "project-id" + keyToken = "token" + + defaultTimeout = 60 * time.Second +) + +func init() { + core.Register(&Gitlab{}) +} + +type Gitlab struct{} + +func (*Gitlab) Target() string { + return target +} + +func (*Gitlab) NewImporter() core.Importer { + return &gitlabImporter{} +} + +func (*Gitlab) NewExporter() core.Exporter { + return nil +} + +func buildClient(token string) *gitlab.Client { + client := &http.Client{ + Timeout: defaultTimeout, + } + + return gitlab.NewClient(client, token) +} diff --git a/bridge/gitlab/import.go b/bridge/gitlab/import.go new file mode 100644 index 00000000..8f4ceec9 --- /dev/null +++ b/bridge/gitlab/import.go @@ -0,0 +1,338 @@ +package gitlab + +import ( + "fmt" + "strconv" + "time" + + "github.com/xanzy/go-gitlab" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/util/git" + "github.com/MichaelMure/git-bug/util/text" +) + +// gitlabImporter implement the Importer interface +type gitlabImporter struct { + conf core.Configuration + + // iterator + iterator *iterator + + // number of imported issues + importedIssues int + + // number of imported identities + importedIdentities int +} + +func (gi *gitlabImporter) Init(conf core.Configuration) error { + gi.conf = conf + 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(repo *cache.RepoCache, since time.Time) error { + gi.iterator = NewIterator(gi.conf[keyProjectID], gi.conf[keyToken], since) + + // Loop over all matching issues + for gi.iterator.NextIssue() { + issue := gi.iterator.IssueValue() + fmt.Printf("importing issue: %v\n", issue.Title) + + // create issue + b, err := gi.ensureIssue(repo, issue) + if err != nil { + return fmt.Errorf("issue creation: %v", err) + } + + // Loop over all notes + for gi.iterator.NextNote() { + note := gi.iterator.NoteValue() + if err := gi.ensureNote(repo, b, note); err != nil { + return fmt.Errorf("note creation: %v", err) + } + } + + // Loop over all label events + for gi.iterator.NextLabelEvent() { + labelEvent := gi.iterator.LabelEventValue() + if err := gi.ensureLabelEvent(repo, b, labelEvent); err != nil { + return fmt.Errorf("label event creation: %v", err) + } + } + + if err := gi.iterator.Error(); err != nil { + fmt.Printf("import error: %v\n", err) + return err + } + + // commit bug state + if err := b.CommitAsNeeded(); err != nil { + return fmt.Errorf("bug commit: %v", err) + } + } + + fmt.Printf("Successfully imported %d issues and %d identities from Gitlab\n", gi.importedIssues, gi.importedIdentities) + return 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.ResolveBugCreateMetadata(keyGitlabUrl, issue.WebURL) + if err != nil && err != bug.ErrBugNotExist { + return nil, err + } + + if err == nil { + return b, nil + } + + // if bug was never imported + cleanText, err := text.Cleanup(issue.Description) + if err != nil { + return nil, err + } + + // create bug + b, _, err = repo.NewBugRaw( + author, + issue.CreatedAt.Unix(), + issue.Title, + cleanText, + nil, + map[string]string{ + core.KeyOrigin: target, + keyGitlabId: parseID(issue.ID), + keyGitlabUrl: issue.WebURL, + keyGitlabProject: gi.conf[keyProjectID], + }, + ) + + if err != nil { + return nil, err + } + + // importing a new bug + gi.importedIssues++ + + return b, nil +} + +func (gi *gitlabImporter) ensureNote(repo *cache.RepoCache, b *cache.BugCache, note *gitlab.Note) error { + id := parseID(note.ID) + + // ensure issue author + author, err := gi.ensurePerson(repo, note.Author.ID) + if err != nil { + return err + } + + noteType, body := GetNoteType(note) + switch noteType { + case NOTE_CLOSED: + _, err = b.CloseRaw( + author, + note.CreatedAt.Unix(), + map[string]string{ + keyGitlabId: id, + }, + ) + return err + + case NOTE_REOPENED: + _, err = b.OpenRaw( + author, + note.CreatedAt.Unix(), + map[string]string{ + keyGitlabId: id, + }, + ) + return err + + case NOTE_DESCRIPTION_CHANGED: + issue := gi.iterator.IssueValue() + + 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 issue.Description != firstComment.Message { + + // comment edition + _, err = b.EditCommentRaw( + author, + note.UpdatedAt.Unix(), + git.Hash(firstComment.Id()), + issue.Description, + map[string]string{ + keyGitlabId: id, + }, + ) + + return err + } + + case NOTE_COMMENT: + hash, errResolve := b.ResolveOperationWithMetadata(keyGitlabId, id) + if errResolve != cache.ErrNoMatchingOp { + return errResolve + } + + cleanText, err := text.Cleanup(body) + if err != nil { + return err + } + + // if we didn't import the comment + if errResolve == cache.ErrNoMatchingOp { + + // add comment operation + _, err = b.AddCommentRaw( + author, + note.CreatedAt.Unix(), + cleanText, + nil, + map[string]string{ + keyGitlabId: id, + }, + ) + + return err + } + + // if comment was already exported + + // search for last comment update + comment, err := b.Snapshot().SearchComment(hash) + if err != nil { + return err + } + + // compare local bug comment with the new note body + if comment.Message != cleanText { + // comment edition + _, err = b.EditCommentRaw( + author, + note.UpdatedAt.Unix(), + git.Hash(comment.Id()), + cleanText, + nil, + ) + + return err + } + + return nil + + case NOTE_TITLE_CHANGED: + // title change events are given new notes + _, err = b.SetTitleRaw( + author, + note.CreatedAt.Unix(), + body, + map[string]string{ + keyGitlabId: id, + }, + ) + + return err + + case NOTE_UNKNOWN: + return nil + + default: + panic("unhandled note type") + } + + return nil +} + +func (gi *gitlabImporter) ensureLabelEvent(repo *cache.RepoCache, b *cache.BugCache, labelEvent *gitlab.LabelEvent) error { + _, err := b.ResolveOperationWithMetadata(keyGitlabId, parseID(labelEvent.ID)) + if err != cache.ErrNoMatchingOp { + return err + } + + // ensure issue author + author, err := gi.ensurePerson(repo, labelEvent.User.ID) + if err != nil { + return err + } + + switch labelEvent.Action { + case "add": + _, err = b.ForceChangeLabelsRaw( + author, + labelEvent.CreatedAt.Unix(), + []string{labelEvent.Label.Name}, + nil, + map[string]string{ + keyGitlabId: parseID(labelEvent.ID), + }, + ) + + case "remove": + _, err = b.ForceChangeLabelsRaw( + author, + labelEvent.CreatedAt.Unix(), + nil, + []string{labelEvent.Label.Name}, + map[string]string{ + keyGitlabId: parseID(labelEvent.ID), + }, + ) + + default: + err = fmt.Errorf("unexpected label event action") + } + + return err +} + +func (gi *gitlabImporter) ensurePerson(repo *cache.RepoCache, id int) (*cache.IdentityCache, error) { + // Look first in the cache + i, err := repo.ResolveIdentityImmutableMetadata(keyGitlabId, strconv.Itoa(id)) + if err == nil { + return i, nil + } + if _, ok := err.(identity.ErrMultipleMatch); ok { + return nil, err + } + + client := buildClient(gi.conf["token"]) + + user, _, err := client.Users.GetUser(id) + if err != nil { + return nil, err + } + + // importing a new identity + gi.importedIdentities++ + + return repo.NewIdentityRaw( + user.Name, + user.PublicEmail, + user.Username, + user.AvatarURL, + map[string]string{ + // because Gitlab + keyGitlabId: strconv.Itoa(id), + keyGitlabLogin: user.Username, + }, + ) +} + +func parseID(id int) string { + return fmt.Sprintf("%d", id) +} diff --git a/bridge/gitlab/import_notes.go b/bridge/gitlab/import_notes.go new file mode 100644 index 00000000..85da3158 --- /dev/null +++ b/bridge/gitlab/import_notes.go @@ -0,0 +1,98 @@ +package gitlab + +import ( + "strings" + + "github.com/xanzy/go-gitlab" +) + +type NoteType int + +const ( + _ NoteType = iota + NOTE_COMMENT + NOTE_TITLE_CHANGED + NOTE_DESCRIPTION_CHANGED + NOTE_CLOSED + NOTE_REOPENED + NOTE_LOCKED + NOTE_UNLOCKED + NOTE_CHANGED_DUEDATE + NOTE_REMOVED_DUEDATE + NOTE_ASSIGNED + NOTE_UNASSIGNED + NOTE_CHANGED_MILESTONE + NOTE_REMOVED_MILESTONE + NOTE_UNKNOWN +) + +// GetNoteType parse a note system and body and return the note type and it content +func GetNoteType(n *gitlab.Note) (NoteType, string) { + // when a note is a comment system is set to false + // when a note is a different event system is set to true + // because Gitlab + if !n.System { + return NOTE_COMMENT, n.Body + } + + if n.Body == "closed" { + return NOTE_CLOSED, "" + } + + if n.Body == "reopened" { + return NOTE_REOPENED, "" + } + + if n.Body == "changed the description" { + return NOTE_DESCRIPTION_CHANGED, "" + } + + if n.Body == "locked this issue" { + return NOTE_LOCKED, "" + } + + if n.Body == "unlocked this issue" { + return NOTE_UNLOCKED, "" + } + + if strings.HasPrefix(n.Body, "changed title from") { + return NOTE_TITLE_CHANGED, getNewTitle(n.Body) + } + + if strings.HasPrefix(n.Body, "changed due date to") { + return NOTE_CHANGED_DUEDATE, "" + } + + if n.Body == "removed due date" { + return NOTE_REMOVED_DUEDATE, "" + } + + if strings.HasPrefix(n.Body, "assigned to @") { + return NOTE_ASSIGNED, "" + } + + if strings.HasPrefix(n.Body, "unassigned @") { + return NOTE_UNASSIGNED, "" + } + + if strings.HasPrefix(n.Body, "changed milestone to %") { + return NOTE_CHANGED_MILESTONE, "" + } + + if strings.HasPrefix(n.Body, "removed milestone") { + return NOTE_REMOVED_MILESTONE, "" + } + + return NOTE_UNKNOWN, "" +} + +// getNewTitle parses body diff given by gitlab api and return it final form +// examples: "changed title from **fourth issue** to **fourth issue{+ changed+}**" +// "changed title from **fourth issue{- changed-}** to **fourth issue**" +// because Gitlab +func getNewTitle(diff string) string { + newTitle := strings.Split(diff, "** to **")[1] + newTitle = strings.Replace(newTitle, "{+", "", -1) + newTitle = strings.Replace(newTitle, "+}", "", -1) + return strings.TrimSuffix(newTitle, "**") +} diff --git a/bridge/gitlab/import_notes_test.go b/bridge/gitlab/import_notes_test.go new file mode 100644 index 00000000..c7b5ab56 --- /dev/null +++ b/bridge/gitlab/import_notes_test.go @@ -0,0 +1,56 @@ +package gitlab + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetNewTitle(t *testing.T) { + type args struct { + diff string + } + type want struct { + title string + } + tests := []struct { + name string + args args + want want + }{ + { + name: "addition diff", + args: args{ + diff: "**first issue** to **first issue{+ edited+}**", + }, + want: want{ + title: "first issue edited", + }, + }, + { + name: "deletion diff", + args: args{ + diff: "**first issue{- edited-}** to **first issue**", + }, + want: want{ + title: "first issue", + }, + }, + { + name: "mixed diff", + args: args{ + diff: "**first {-issue-}** to **first {+bug+}**", + }, + want: want{ + title: "first bug", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + title := getNewTitle(tt.args.diff) + assert.Equal(t, tt.want.title, title) + }) + } +} diff --git a/bridge/gitlab/import_test.go b/bridge/gitlab/import_test.go new file mode 100644 index 00000000..c38d3ce3 --- /dev/null +++ b/bridge/gitlab/import_test.go @@ -0,0 +1,151 @@ +package gitlab + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/bug" + "github.com/MichaelMure/git-bug/cache" + "github.com/MichaelMure/git-bug/identity" + "github.com/MichaelMure/git-bug/repository" + "github.com/MichaelMure/git-bug/util/interrupt" +) + +func TestImport(t *testing.T) { + author := identity.NewIdentity("Amine hilaly", "hilalyamine@gmail.com") + tests := []struct { + name string + url string + bug *bug.Snapshot + }{ + { + name: "simple issue", + url: "https://gitlab.com/git-bug/test/issues/1", + bug: &bug.Snapshot{ + Operations: []bug.Operation{ + bug.NewCreateOp(author, 0, "simple issue", "initial comment", nil), + bug.NewAddCommentOp(author, 0, "first comment", nil), + bug.NewAddCommentOp(author, 0, "second comment", nil), + }, + }, + }, + { + name: "empty issue", + url: "https://gitlab.com/git-bug/test/issues/2", + bug: &bug.Snapshot{ + Operations: []bug.Operation{ + bug.NewCreateOp(author, 0, "empty issue", "", nil), + }, + }, + }, + { + name: "complex issue", + url: "https://gitlab.com/git-bug/test/issues/3", + bug: &bug.Snapshot{ + Operations: []bug.Operation{ + bug.NewCreateOp(author, 0, "complex issue", "initial comment", nil), + bug.NewAddCommentOp(author, 0, "### header\n\n**bold**\n\n_italic_\n\n> with quote\n\n`inline code`\n\n```\nmultiline code\n```\n\n- bulleted\n- list\n\n1. numbered\n1. list\n\n- [ ] task\n- [x] list\n\n@MichaelMure mention\n\n#2 reference issue\n#3 auto-reference issue", nil), + bug.NewSetTitleOp(author, 0, "complex issue edited", "complex issue"), + bug.NewSetTitleOp(author, 0, "complex issue", "complex issue edited"), + bug.NewSetStatusOp(author, 0, bug.ClosedStatus), + bug.NewSetStatusOp(author, 0, bug.OpenStatus), + bug.NewLabelChangeOperation(author, 0, []bug.Label{"bug"}, []bug.Label{}), + bug.NewLabelChangeOperation(author, 0, []bug.Label{"critical"}, []bug.Label{}), + bug.NewLabelChangeOperation(author, 0, []bug.Label{}, []bug.Label{"critical"}), + }, + }, + }, + { + name: "editions", + url: "https://gitlab.com/git-bug/test/issues/4", + bug: &bug.Snapshot{ + Operations: []bug.Operation{ + bug.NewCreateOp(author, 0, "editions", "initial comment edited", nil), + bug.NewAddCommentOp(author, 0, "first comment edited", nil), + }, + }, + }, + } + + repo := repository.CreateTestRepo(false) + defer repository.CleanupTestRepos(t, repo) + + backend, err := cache.NewRepoCache(repo) + require.NoError(t, err) + + defer backend.Close() + interrupt.RegisterCleaner(backend.Close) + + token := os.Getenv("GITLAB_API_TOKEN") + if token == "" { + t.Skip("Env var GITLAB_API_TOKEN missing") + } + + projectID := os.Getenv("GITLAB_PROJECT_ID") + if projectID == "" { + t.Skip("Env var GITLAB_PROJECT_ID missing") + } + + importer := &gitlabImporter{} + err = importer.Init(core.Configuration{ + keyProjectID: projectID, + keyToken: token, + }) + require.NoError(t, err) + + start := time.Now() + err = importer.ImportAll(backend, time.Time{}) + require.NoError(t, err) + + fmt.Printf("test repository imported in %f seconds\n", time.Since(start).Seconds()) + + require.Len(t, backend.AllBugsIds(), len(tests)) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := backend.ResolveBugCreateMetadata(keyGitlabUrl, tt.url) + require.NoError(t, err) + + ops := b.Snapshot().Operations + require.Len(t, tt.bug.Operations, len(ops)) + + for i, op := range tt.bug.Operations { + + require.IsType(t, ops[i], op) + + switch op.(type) { + case *bug.CreateOperation: + assert.Equal(t, op.(*bug.CreateOperation).Title, ops[i].(*bug.CreateOperation).Title) + assert.Equal(t, op.(*bug.CreateOperation).Message, ops[i].(*bug.CreateOperation).Message) + assert.Equal(t, op.(*bug.CreateOperation).Author.Name(), ops[i].(*bug.CreateOperation).Author.Name()) + case *bug.SetStatusOperation: + assert.Equal(t, op.(*bug.SetStatusOperation).Status, ops[i].(*bug.SetStatusOperation).Status) + assert.Equal(t, op.(*bug.SetStatusOperation).Author.Name(), ops[i].(*bug.SetStatusOperation).Author.Name()) + case *bug.SetTitleOperation: + assert.Equal(t, op.(*bug.SetTitleOperation).Was, ops[i].(*bug.SetTitleOperation).Was) + assert.Equal(t, op.(*bug.SetTitleOperation).Title, ops[i].(*bug.SetTitleOperation).Title) + assert.Equal(t, op.(*bug.SetTitleOperation).Author.Name(), ops[i].(*bug.SetTitleOperation).Author.Name()) + case *bug.LabelChangeOperation: + assert.ElementsMatch(t, op.(*bug.LabelChangeOperation).Added, ops[i].(*bug.LabelChangeOperation).Added) + assert.ElementsMatch(t, op.(*bug.LabelChangeOperation).Removed, ops[i].(*bug.LabelChangeOperation).Removed) + assert.Equal(t, op.(*bug.LabelChangeOperation).Author.Name(), ops[i].(*bug.LabelChangeOperation).Author.Name()) + case *bug.AddCommentOperation: + assert.Equal(t, op.(*bug.AddCommentOperation).Message, ops[i].(*bug.AddCommentOperation).Message) + assert.Equal(t, op.(*bug.AddCommentOperation).Author.Name(), ops[i].(*bug.AddCommentOperation).Author.Name()) + case *bug.EditCommentOperation: + assert.Equal(t, op.(*bug.EditCommentOperation).Message, ops[i].(*bug.EditCommentOperation).Message) + assert.Equal(t, op.(*bug.EditCommentOperation).Author.Name(), ops[i].(*bug.EditCommentOperation).Author.Name()) + + default: + panic("unknown operation type") + } + } + }) + } +} diff --git a/bridge/gitlab/iterator.go b/bridge/gitlab/iterator.go new file mode 100644 index 00000000..883fea9c --- /dev/null +++ b/bridge/gitlab/iterator.go @@ -0,0 +1,242 @@ +package gitlab + +import ( + "time" + + "github.com/xanzy/go-gitlab" +) + +type issueIterator struct { + page int + index int + cache []*gitlab.Issue +} + +type noteIterator struct { + page int + index int + cache []*gitlab.Note +} + +type labelEventIterator struct { + page int + index int + cache []*gitlab.LabelEvent +} + +type iterator struct { + // gitlab api v4 client + gc *gitlab.Client + + // if since is given the iterator will query only the issues + // updated after this date + since time.Time + + // project id + project string + + // number of issues and notes to query at once + capacity int + + // sticky error + err error + + // issues iterator + issue *issueIterator + + // notes iterator + note *noteIterator + + // labelEvent iterator + labelEvent *labelEventIterator +} + +// NewIterator create a new iterator +func NewIterator(projectID, token string, since time.Time) *iterator { + return &iterator{ + gc: buildClient(token), + project: projectID, + since: since, + capacity: 20, + issue: &issueIterator{ + index: -1, + page: 1, + }, + note: ¬eIterator{ + index: -1, + page: 1, + }, + labelEvent: &labelEventIterator{ + index: -1, + page: 1, + }, + } +} + +// Error return last encountered error +func (i *iterator) Error() error { + return i.err +} + +func (i *iterator) getNextIssues() bool { + issues, _, err := i.gc.Issues.ListProjectIssues( + i.project, + &gitlab.ListProjectIssuesOptions{ + ListOptions: gitlab.ListOptions{ + Page: i.issue.page, + PerPage: i.capacity, + }, + Scope: gitlab.String("all"), + UpdatedAfter: &i.since, + Sort: gitlab.String("asc"), + }, + ) + + if err != nil { + i.err = err + return false + } + + // if repository doesn't have any issues + if len(issues) == 0 { + return false + } + + i.issue.cache = issues + i.issue.index = 0 + i.issue.page++ + i.note.index = -1 + i.note.cache = nil + + return true +} + +func (i *iterator) NextIssue() bool { + if i.err != nil { + return false + } + + // first query + if i.issue.cache == nil { + return i.getNextIssues() + } + + // move cursor index + if i.issue.index < len(i.issue.cache)-1 { + i.issue.index++ + return true + } + + return i.getNextIssues() +} + +func (i *iterator) IssueValue() *gitlab.Issue { + return i.issue.cache[i.issue.index] +} + +func (i *iterator) getNextNotes() bool { + notes, _, err := i.gc.Notes.ListIssueNotes( + i.project, + i.IssueValue().IID, + &gitlab.ListIssueNotesOptions{ + ListOptions: gitlab.ListOptions{ + Page: i.note.page, + PerPage: i.capacity, + }, + Sort: gitlab.String("asc"), + OrderBy: gitlab.String("created_at"), + }, + ) + + if err != nil { + i.err = err + return false + } + + if len(notes) == 0 { + i.note.index = -1 + i.note.page = 1 + i.note.cache = nil + return false + } + + i.note.cache = notes + i.note.page++ + i.note.index = 0 + return true +} + +func (i *iterator) NextNote() bool { + if i.err != nil { + return false + } + + if len(i.note.cache) == 0 { + return i.getNextNotes() + } + + // move cursor index + if i.note.index < len(i.note.cache)-1 { + i.note.index++ + return true + } + + return i.getNextNotes() +} + +func (i *iterator) NoteValue() *gitlab.Note { + return i.note.cache[i.note.index] +} + +func (i *iterator) getNextLabelEvents() bool { + labelEvents, _, err := i.gc.ResourceLabelEvents.ListIssueLabelEvents( + i.project, + i.IssueValue().IID, + &gitlab.ListLabelEventsOptions{ + ListOptions: gitlab.ListOptions{ + Page: i.labelEvent.page, + PerPage: i.capacity, + }, + }, + ) + + if err != nil { + i.err = err + return false + } + + if len(labelEvents) == 0 { + i.labelEvent.page = 1 + i.labelEvent.index = -1 + i.labelEvent.cache = nil + return false + } + + i.labelEvent.cache = labelEvents + i.labelEvent.page++ + i.labelEvent.index = 0 + return true +} + +// because Gitlab +func (i *iterator) NextLabelEvent() bool { + if i.err != nil { + return false + } + + if len(i.labelEvent.cache) == 0 { + return i.getNextLabelEvents() + } + + // move cursor index + if i.labelEvent.index < len(i.labelEvent.cache)-1 { + i.labelEvent.index++ + return true + } + + return i.getNextLabelEvents() +} + +func (i *iterator) LabelEventValue() *gitlab.LabelEvent { + return i.labelEvent.cache[i.labelEvent.index] +} |