aboutsummaryrefslogtreecommitdiffstats
path: root/bridge/gitlab
diff options
context:
space:
mode:
authorMichael Muré <batolettre@gmail.com>2019-07-23 19:50:58 +0200
committerGitHub <noreply@github.com>2019-07-23 19:50:58 +0200
commit9ecbcb1cf6348b95b31ccef3f9722be078dbe223 (patch)
treed855b993905051d5ff5dbc3e30460bc09fa2e2c4 /bridge/gitlab
parentca00c9c6b84f0b1333e40666ab979d0d8fdc4036 (diff)
parent29fdd37ce69b48aa9fc3c1b829ff67818041068f (diff)
downloadgit-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.go218
-rw-r--r--bridge/gitlab/config_test.go90
-rw-r--r--bridge/gitlab/export.go27
-rw-r--r--bridge/gitlab/export_test.go1
-rw-r--r--bridge/gitlab/gitlab.go50
-rw-r--r--bridge/gitlab/import.go338
-rw-r--r--bridge/gitlab/import_notes.go98
-rw-r--r--bridge/gitlab/import_notes_test.go56
-rw-r--r--bridge/gitlab/import_test.go151
-rw-r--r--bridge/gitlab/iterator.go242
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: &noteIterator{
+ 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]
+}