]> source.dussan.org Git - gitea.git/commitdiff
Add migrate from OneDev (#16356)
authorKN4CK3R <admin@oldschoolhack.me>
Sat, 21 Aug 2021 22:47:45 +0000 (00:47 +0200)
committerGitHub <noreply@github.com>
Sat, 21 Aug 2021 22:47:45 +0000 (00:47 +0200)
* Use context to simplify logic.

* Added migration from OneDev.
This PR adds [OneDev](https://code.onedev.io/) as migration source.

Supported:
- [x] Milestones
- [x] Issues
- [x] Pull Requests
- [x] Comments
- [x] Reviews
- [x] Labels

24 files changed:
modules/convert/utils.go
modules/migrations/base/downloader.go
modules/migrations/base/issue.go
modules/migrations/base/null_downloader.go
modules/migrations/base/pullrequest.go
modules/migrations/base/retry_downloader.go
modules/migrations/gitea_downloader.go
modules/migrations/gitea_downloader_test.go
modules/migrations/gitea_uploader.go
modules/migrations/github.go
modules/migrations/github_test.go
modules/migrations/gitlab.go
modules/migrations/gitlab_test.go
modules/migrations/gogs.go
modules/migrations/gogs_test.go
modules/migrations/migrate.go
modules/migrations/onedev.go [new file with mode: 0644]
modules/migrations/onedev_test.go [new file with mode: 0644]
modules/migrations/restore.go
modules/structs/repo.go
options/locale/locale_en-US.ini
public/img/svg/gitea-onedev.svg [new file with mode: 0644]
templates/repo/migrate/onedev.tmpl [new file with mode: 0644]
web_src/svg/gitea-onedev.svg [new file with mode: 0644]

index 69de306689e04c4dbcadd7fd9e23d84782d0e7b0..a0463d7b10069daffbb85b4ea65b24bd788952a3 100644 (file)
@@ -33,6 +33,8 @@ func ToGitServiceType(value string) structs.GitServiceType {
                return structs.GitlabService
        case "gogs":
                return structs.GogsService
+       case "onedev":
+               return structs.OneDevService
        default:
                return structs.PlainGitService
        }
index 71c8f3eaf9788b6fd6079b12850702fb8f17fa60..3c581b869900b24ad936b0aed3b87305b99c71f9 100644 (file)
@@ -13,9 +13,9 @@ import (
 
 // GetCommentOptions represents an options for get comment
 type GetCommentOptions struct {
-       IssueNumber int64
-       Page        int
-       PageSize    int
+       Context  IssueContext
+       Page     int
+       PageSize int
 }
 
 // Downloader downloads the site repo information
@@ -30,7 +30,7 @@ type Downloader interface {
        GetComments(opts GetCommentOptions) ([]*Comment, bool, error)
        SupportGetRepoComments() bool
        GetPullRequests(page, perPage int) ([]*PullRequest, bool, error)
-       GetReviews(pullRequestNumber int64) ([]*Review, error)
+       GetReviews(pullRequestContext IssueContext) ([]*Review, error)
        FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error)
 }
 
index 8b1b4612444d7305105302166f8b8a35b3dadba4..7addd1336a2840d03997faceb7dfcab479564c2b 100644 (file)
@@ -7,6 +7,25 @@ package base
 
 import "time"
 
+// IssueContext is used to map between local and foreign issue/PR ids.
+type IssueContext interface {
+       LocalID() int64
+       ForeignID() int64
+}
+
+// BasicIssueContext is a 1:1 mapping between local and foreign ids.
+type BasicIssueContext int64
+
+// LocalID gets the local id.
+func (c BasicIssueContext) LocalID() int64 {
+       return int64(c)
+}
+
+// ForeignID gets the foreign id.
+func (c BasicIssueContext) ForeignID() int64 {
+       return int64(c)
+}
+
 // Issue is a standard issue information
 type Issue struct {
        Number      int64
@@ -25,4 +44,5 @@ type Issue struct {
        Labels      []*Label
        Reactions   []*Reaction
        Assignees   []string
+       Context     IssueContext `yaml:"-"`
 }
index 53a536709d1ffc75efd6e39508ba5705f4b4d50f..c64d0e26337905e983a83a8e4db2474b11169586 100644 (file)
@@ -50,7 +50,7 @@ func (n NullDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
        return nil, false, &ErrNotSupported{Entity: "Issues"}
 }
 
-// GetComments returns comments according issueNumber
+// GetComments returns comments according the options
 func (n NullDownloader) GetComments(GetCommentOptions) ([]*Comment, bool, error) {
        return nil, false, &ErrNotSupported{Entity: "Comments"}
 }
@@ -61,7 +61,7 @@ func (n NullDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool
 }
 
 // GetReviews returns pull requests review
-func (n NullDownloader) GetReviews(pullRequestNumber int64) ([]*Review, error) {
+func (n NullDownloader) GetReviews(pullRequestContext IssueContext) ([]*Review, error) {
        return nil, &ErrNotSupported{Entity: "Reviews"}
 }
 
index 6411137d0aa33140c624be8e087e76598282e5d8..84b302d18fada8bdc552c8c451e548004fc8967b 100644 (file)
@@ -13,7 +13,6 @@ import (
 // PullRequest defines a standard pull request information
 type PullRequest struct {
        Number         int64
-       OriginalNumber int64 `yaml:"original_number"`
        Title          string
        PosterName     string `yaml:"poster_name"`
        PosterID       int64  `yaml:"poster_id"`
@@ -34,6 +33,7 @@ type PullRequest struct {
        Assignees      []string
        IsLocked       bool `yaml:"is_locked"`
        Reactions      []*Reaction
+       Context        IssueContext `yaml:"-"`
 }
 
 // IsForkPullRequest returns true if the pull request from a forked repository but not the same repository
index e6c80038f1812892f1b8efc816d9f5b628bc2eef..623bfc86b526806cbaff50be39ff13f23cb459a5 100644 (file)
@@ -182,14 +182,14 @@ func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bo
 }
 
 // GetReviews returns pull requests reviews
-func (d *RetryDownloader) GetReviews(pullRequestNumber int64) ([]*Review, error) {
+func (d *RetryDownloader) GetReviews(pullRequestContext IssueContext) ([]*Review, error) {
        var (
                reviews []*Review
                err     error
        )
 
        err = d.retry(func() error {
-               reviews, err = d.Downloader.GetReviews(pullRequestNumber)
+               reviews, err = d.Downloader.GetReviews(pullRequestContext)
                return err
        })
 
index 23ede93a4208873f545edc0c7c0e902a549cb495..b947ee74a42236a6b6df09fee605f2a968c9474c 100644 (file)
@@ -444,6 +444,7 @@ func (g *GiteaDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, err
                        Labels:      labels,
                        Assignees:   assignees,
                        IsLocked:    issue.IsLocked,
+                       Context:     base.BasicIssueContext(issue.Index),
                })
        }
 
@@ -466,26 +467,26 @@ func (g *GiteaDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comm
        default:
        }
 
-       comments, _, err := g.client.ListIssueComments(g.repoOwner, g.repoName, opts.IssueNumber, gitea_sdk.ListIssueCommentOptions{ListOptions: gitea_sdk.ListOptions{
+       comments, _, err := g.client.ListIssueComments(g.repoOwner, g.repoName, opts.Context.ForeignID(), gitea_sdk.ListIssueCommentOptions{ListOptions: gitea_sdk.ListOptions{
                // PageSize: g.maxPerPage,
                // Page:     i,
        }})
        if err != nil {
-               return nil, false, fmt.Errorf("error while listing comments for issue #%d. Error: %v", opts.IssueNumber, err)
+               return nil, false, fmt.Errorf("error while listing comments for issue #%d. Error: %v", opts.Context.ForeignID(), err)
        }
 
        for _, comment := range comments {
                reactions, err := g.getCommentReactions(comment.ID)
                if err != nil {
-                       log.Warn("Unable to load comment reactions during migrating issue #%d for comment %d to %s/%s. Error: %v", opts.IssueNumber, comment.ID, g.repoOwner, g.repoName, err)
+                       log.Warn("Unable to load comment reactions during migrating issue #%d for comment %d to %s/%s. Error: %v", opts.Context.ForeignID(), comment.ID, g.repoOwner, g.repoName, err)
                        if err2 := models.CreateRepositoryNotice(
-                               fmt.Sprintf("Unable to load reactions during migrating issue #%d for comment %d to %s/%s. Error: %v", opts.IssueNumber, comment.ID, g.repoOwner, g.repoName, err)); err2 != nil {
+                               fmt.Sprintf("Unable to load reactions during migrating issue #%d for comment %d to %s/%s. Error: %v", opts.Context.ForeignID(), comment.ID, g.repoOwner, g.repoName, err)); err2 != nil {
                                log.Error("create repository notice failed: ", err2)
                        }
                }
 
                allComments = append(allComments, &base.Comment{
-                       IssueIndex:  opts.IssueNumber,
+                       IssueIndex:  opts.Context.LocalID(),
                        PosterID:    comment.Poster.ID,
                        PosterName:  comment.Poster.UserName,
                        PosterEmail: comment.Poster.Email,
@@ -615,6 +616,7 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
                                RepoName:  g.repoName,
                                OwnerName: g.repoOwner,
                        },
+                       Context: base.BasicIssueContext(pr.Index),
                })
        }
 
@@ -626,7 +628,7 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
 }
 
 // GetReviews returns pull requests review
-func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
+func (g *GiteaDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) {
        if err := g.client.CheckServerVersionConstraint(">=1.12"); err != nil {
                log.Info("GiteaDownloader: instance to old, skip GetReviews")
                return nil, nil
@@ -642,7 +644,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
                default:
                }
 
-               prl, _, err := g.client.ListPullReviews(g.repoOwner, g.repoName, index, gitea_sdk.ListPullReviewsOptions{ListOptions: gitea_sdk.ListOptions{
+               prl, _, err := g.client.ListPullReviews(g.repoOwner, g.repoName, context.ForeignID(), gitea_sdk.ListPullReviewsOptions{ListOptions: gitea_sdk.ListOptions{
                        Page:     i,
                        PageSize: g.maxPerPage,
                }})
@@ -652,7 +654,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
 
                for _, pr := range prl {
 
-                       rcl, _, err := g.client.ListPullReviewComments(g.repoOwner, g.repoName, index, pr.ID)
+                       rcl, _, err := g.client.ListPullReviewComments(g.repoOwner, g.repoName, context.ForeignID(), pr.ID)
                        if err != nil {
                                return nil, err
                        }
@@ -678,7 +680,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
 
                        allReviews = append(allReviews, &base.Review{
                                ID:           pr.ID,
-                               IssueIndex:   index,
+                               IssueIndex:   context.LocalID(),
                                ReviewerID:   pr.Reviewer.ID,
                                ReviewerName: pr.Reviewer.UserName,
                                Official:     pr.Official,
index 7ce8aa0e0bd94aa0cc4dad188d8e5a25f613f994..71bdecaeaddde6e4b3414405d51a8038e7f46b35 100644 (file)
@@ -199,7 +199,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
        }, issues)
 
        comments, _, err := downloader.GetComments(base.GetCommentOptions{
-               IssueNumber: 4,
+               Context: base.BasicIssueContext(4),
        })
        assert.NoError(t, err)
        assertCommentsEqual(t, []*base.Comment{
@@ -265,7 +265,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
                PatchURL:       "https://gitea.com/gitea/test_repo/pulls/12.patch",
        }, prs[1])
 
-       reviews, err := downloader.GetReviews(7)
+       reviews, err := downloader.GetReviews(base.BasicIssueContext(7))
        assert.NoError(t, err)
        assertReviewsEqual(t, []*base.Review{
                {
index 01fb9bda8af4630f66f8d690f38754d8e37bc91e..c77ace797bc0bc6bee2c0ba4ebdf6842c2d4be60 100644 (file)
@@ -609,6 +609,9 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*models.PullR
 
        // download patch file
        err := func() error {
+               if pr.PatchURL == "" {
+                       return nil
+               }
                // pr.PatchURL maybe a local file
                ret, err := uri.Open(pr.PatchURL)
                if err != nil {
index f6063b06613eb5058e34a7ae37e69cb425abcc7a..54af10d116aa91a8bc5b5ba8f8f66d9b3c558164 100644 (file)
@@ -428,6 +428,7 @@ func (g *GithubDownloaderV3) GetIssues(page, perPage int) ([]*base.Issue, bool,
                        Closed:      issue.ClosedAt,
                        IsLocked:    issue.GetLocked(),
                        Assignees:   assignees,
+                       Context:     base.BasicIssueContext(*issue.Number),
                })
        }
 
@@ -441,15 +442,15 @@ func (g *GithubDownloaderV3) SupportGetRepoComments() bool {
 
 // GetComments returns comments according issueNumber
 func (g *GithubDownloaderV3) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
-       if opts.IssueNumber > 0 {
-               comments, err := g.getComments(opts.IssueNumber)
+       if opts.Context != nil {
+               comments, err := g.getComments(opts.Context)
                return comments, false, err
        }
 
        return g.GetAllComments(opts.Page, opts.PageSize)
 }
 
-func (g *GithubDownloaderV3) getComments(issueNumber int64) ([]*base.Comment, error) {
+func (g *GithubDownloaderV3) getComments(issueContext base.IssueContext) ([]*base.Comment, error) {
        var (
                allComments = make([]*base.Comment, 0, g.maxPerPage)
                created     = "created"
@@ -464,7 +465,7 @@ func (g *GithubDownloaderV3) getComments(issueNumber int64) ([]*base.Comment, er
        }
        for {
                g.sleep()
-               comments, resp, err := g.client.Issues.ListComments(g.ctx, g.repoOwner, g.repoName, int(issueNumber), opt)
+               comments, resp, err := g.client.Issues.ListComments(g.ctx, g.repoOwner, g.repoName, int(issueContext.ForeignID()), opt)
                if err != nil {
                        return nil, fmt.Errorf("error while listing repos: %v", err)
                }
@@ -495,7 +496,7 @@ func (g *GithubDownloaderV3) getComments(issueNumber int64) ([]*base.Comment, er
                        }
 
                        allComments = append(allComments, &base.Comment{
-                               IssueIndex:  issueNumber,
+                               IssueIndex:  issueContext.LocalID(),
                                PosterID:    comment.GetUser().GetID(),
                                PosterName:  comment.GetUser().GetLogin(),
                                PosterEmail: comment.GetUser().GetEmail(),
@@ -661,6 +662,7 @@ func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullReq
                        },
                        PatchURL:  pr.GetPatchURL(),
                        Reactions: reactions,
+                       Context:   base.BasicIssueContext(*pr.Number),
                })
        }
 
@@ -724,28 +726,28 @@ func (g *GithubDownloaderV3) convertGithubReviewComments(cs []*github.PullReques
 }
 
 // GetReviews returns pull requests review
-func (g *GithubDownloaderV3) GetReviews(pullRequestNumber int64) ([]*base.Review, error) {
+func (g *GithubDownloaderV3) GetReviews(context base.IssueContext) ([]*base.Review, error) {
        var allReviews = make([]*base.Review, 0, g.maxPerPage)
        opt := &github.ListOptions{
                PerPage: g.maxPerPage,
        }
        for {
                g.sleep()
-               reviews, resp, err := g.client.PullRequests.ListReviews(g.ctx, g.repoOwner, g.repoName, int(pullRequestNumber), opt)
+               reviews, resp, err := g.client.PullRequests.ListReviews(g.ctx, g.repoOwner, g.repoName, int(context.ForeignID()), opt)
                if err != nil {
                        return nil, fmt.Errorf("error while listing repos: %v", err)
                }
                g.rate = &resp.Rate
                for _, review := range reviews {
                        r := convertGithubReview(review)
-                       r.IssueIndex = pullRequestNumber
+                       r.IssueIndex = context.LocalID()
                        // retrieve all review comments
                        opt2 := &github.ListOptions{
                                PerPage: g.maxPerPage,
                        }
                        for {
                                g.sleep()
-                               reviewComments, resp, err := g.client.PullRequests.ListReviewComments(g.ctx, g.repoOwner, g.repoName, int(pullRequestNumber), review.GetID(), opt2)
+                               reviewComments, resp, err := g.client.PullRequests.ListReviewComments(g.ctx, g.repoOwner, g.repoName, int(context.ForeignID()), review.GetID(), opt2)
                                if err != nil {
                                        return nil, fmt.Errorf("error while listing repos: %v", err)
                                }
index 16d79d200cae1754169ae6f6068f7180bf288269..4a53f20a76dd3b6e65ff096395d6462513f21064 100644 (file)
@@ -216,7 +216,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
 
        // downloader.GetComments()
        comments, _, err := downloader.GetComments(base.GetCommentOptions{
-               IssueNumber: 2,
+               Context: base.BasicIssueContext(2),
        })
        assert.NoError(t, err)
        assertCommentsEqual(t, []*base.Comment{
@@ -286,6 +286,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
                        Merged:         true,
                        MergedTime:     timePtr(time.Date(2019, 11, 12, 21, 39, 27, 0, time.UTC)),
                        MergeCommitSHA: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+                       Context:        base.BasicIssueContext(3),
                },
                {
                        Number:     4,
@@ -332,10 +333,11 @@ func TestGitHubDownloadRepo(t *testing.T) {
                                        Content:  "+1",
                                },
                        },
+                       Context: base.BasicIssueContext(4),
                },
        }, prs)
 
-       reviews, err := downloader.GetReviews(3)
+       reviews, err := downloader.GetReviews(base.BasicIssueContext(3))
        assert.NoError(t, err)
        assertReviewsEqual(t, []*base.Review{
                {
@@ -367,7 +369,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
                },
        }, reviews)
 
-       reviews, err = downloader.GetReviews(4)
+       reviews, err = downloader.GetReviews(base.BasicIssueContext(4))
        assert.NoError(t, err)
        assertReviewsEqual(t, []*base.Review{
                {
index 28e9eac63c009dfaec7a5d98e107744de71d99f4..d5bf2d2d762dd7c65062087a39506b5b7fa6e206 100644 (file)
@@ -63,17 +63,14 @@ func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType {
 // from gitlab via go-gitlab
 // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap,
 // because Gitlab has individual Issue and Pull Request numbers.
-// - issueSeen, working alongside issueCount, is checked in GetComments() to see whether we
-// need to fetch the Issue or PR comments, as Gitlab stores them separately.
 type GitlabDownloader struct {
        base.NullDownloader
-       ctx             context.Context
-       client          *gitlab.Client
-       repoID          int
-       repoName        string
-       issueCount      int64
-       fetchPRcomments bool
-       maxPerPage      int
+       ctx        context.Context
+       client     *gitlab.Client
+       repoID     int
+       repoName   string
+       issueCount int64
+       maxPerPage int
 }
 
 // NewGitlabDownloader creates a gitlab Downloader via gitlab API
@@ -364,6 +361,20 @@ func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
        return releases, nil
 }
 
+type gitlabIssueContext struct {
+       foreignID      int64
+       localID        int64
+       IsMergeRequest bool
+}
+
+func (c gitlabIssueContext) LocalID() int64 {
+       return c.localID
+}
+
+func (c gitlabIssueContext) ForeignID() int64 {
+       return c.foreignID
+}
+
 // GetIssues returns issues according start and limit
 //   Note: issue label description and colors are not supported by the go-gitlab library at this time
 func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
@@ -433,6 +444,11 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
                        Closed:     issue.ClosedAt,
                        IsLocked:   issue.DiscussionLocked,
                        Updated:    *issue.UpdatedAt,
+                       Context: gitlabIssueContext{
+                               foreignID:      int64(issue.IID),
+                               localID:        int64(issue.IID),
+                               IsMergeRequest: false,
+                       },
                })
 
                // increment issueCount, to be used in GetPullRequests()
@@ -445,27 +461,26 @@ func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, er
 // GetComments returns comments according issueNumber
 // TODO: figure out how to transfer comment reactions
 func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
-       var issueNumber = opts.IssueNumber
+       context, ok := opts.Context.(gitlabIssueContext)
+       if !ok {
+               return nil, false, fmt.Errorf("unexpected context: %+v", opts.Context)
+       }
+
        var allComments = make([]*base.Comment, 0, g.maxPerPage)
 
        var page = 1
-       var realIssueNumber int64
 
        for {
                var comments []*gitlab.Discussion
                var resp *gitlab.Response
                var err error
-               // fetchPRcomments decides whether to fetch Issue or PR comments
-               if !g.fetchPRcomments {
-                       realIssueNumber = issueNumber
-                       comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(realIssueNumber), &gitlab.ListIssueDiscussionsOptions{
+               if !context.IsMergeRequest {
+                       comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListIssueDiscussionsOptions{
                                Page:    page,
                                PerPage: g.maxPerPage,
                        }, nil, gitlab.WithContext(g.ctx))
                } else {
-                       // If this is a PR, we need to figure out the Gitlab/original PR ID to be passed below
-                       realIssueNumber = issueNumber - g.issueCount
-                       comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(realIssueNumber), &gitlab.ListMergeRequestDiscussionsOptions{
+                       comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListMergeRequestDiscussionsOptions{
                                Page:    page,
                                PerPage: g.maxPerPage,
                        }, nil, gitlab.WithContext(g.ctx))
@@ -479,7 +494,7 @@ func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Com
                        if !comment.IndividualNote {
                                for _, note := range comment.Notes {
                                        allComments = append(allComments, &base.Comment{
-                                               IssueIndex:  realIssueNumber,
+                                               IssueIndex:  context.LocalID(),
                                                PosterID:    int64(note.Author.ID),
                                                PosterName:  note.Author.Username,
                                                PosterEmail: note.Author.Email,
@@ -490,7 +505,7 @@ func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Com
                        } else {
                                c := comment.Notes[0]
                                allComments = append(allComments, &base.Comment{
-                                       IssueIndex:  realIssueNumber,
+                                       IssueIndex:  context.LocalID(),
                                        PosterID:    int64(c.Author.ID),
                                        PosterName:  c.Author.Username,
                                        PosterEmail: c.Author.Email,
@@ -521,9 +536,6 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
                },
        }
 
-       // Set fetchPRcomments to true here, so PR comments are fetched instead of Issue comments
-       g.fetchPRcomments = true
-
        var allPRs = make([]*base.PullRequest, 0, perPage)
 
        prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
@@ -587,7 +599,6 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
                allPRs = append(allPRs, &base.PullRequest{
                        Title:          pr.Title,
                        Number:         newPRNumber,
-                       OriginalNumber: int64(pr.IID),
                        PosterName:     pr.Author.Username,
                        PosterID:       int64(pr.Author.ID),
                        Content:        pr.Description,
@@ -615,6 +626,11 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
                                OwnerName: pr.Author.Username,
                        },
                        PatchURL: pr.WebURL + ".patch",
+                       Context: gitlabIssueContext{
+                               foreignID:      int64(pr.IID),
+                               localID:        newPRNumber,
+                               IsMergeRequest: true,
+                       },
                })
        }
 
@@ -622,8 +638,8 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
 }
 
 // GetReviews returns pull requests review
-func (g *GitlabDownloader) GetReviews(pullRequestNumber int64) ([]*base.Review, error) {
-       approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(pullRequestNumber), gitlab.WithContext(g.ctx))
+func (g *GitlabDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) {
+       approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(context.ForeignID()), gitlab.WithContext(g.ctx))
        if err != nil {
                if resp != nil && resp.StatusCode == 404 {
                        log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error()))
@@ -635,6 +651,7 @@ func (g *GitlabDownloader) GetReviews(pullRequestNumber int64) ([]*base.Review,
        var reviews = make([]*base.Review, 0, len(approvals.ApprovedBy))
        for _, user := range approvals.ApprovedBy {
                reviews = append(reviews, &base.Review{
+                       IssueIndex:   context.LocalID(),
                        ReviewerID:   int64(user.User.ID),
                        ReviewerName: user.User.Username,
                        CreatedAt:    *approvals.UpdatedAt,
index 8fd915e0f51b48ae5b36a34e5d1ebf55ce097400..c3ee8118c55d937d52e0d81bb505b27cb11baa3e 100644 (file)
@@ -210,7 +210,11 @@ func TestGitlabDownloadRepo(t *testing.T) {
        }, issues)
 
        comments, _, err := downloader.GetComments(base.GetCommentOptions{
-               IssueNumber: 2,
+               Context: gitlabIssueContext{
+                       foreignID:      2,
+                       localID:        2,
+                       IsMergeRequest: false,
+               },
        })
        assert.NoError(t, err)
        assertCommentsEqual(t, []*base.Comment{
@@ -252,15 +256,14 @@ func TestGitlabDownloadRepo(t *testing.T) {
        assert.NoError(t, err)
        assertPullRequestsEqual(t, []*base.PullRequest{
                {
-                       Number:         4,
-                       OriginalNumber: 2,
-                       Title:          "Test branch",
-                       Content:        "do not merge this PR",
-                       Milestone:      "1.0.0",
-                       PosterID:       1241334,
-                       PosterName:     "lafriks",
-                       State:          "opened",
-                       Created:        time.Date(2019, 11, 28, 15, 56, 54, 104000000, time.UTC),
+                       Number:     4,
+                       Title:      "Test branch",
+                       Content:    "do not merge this PR",
+                       Milestone:  "1.0.0",
+                       PosterID:   1241334,
+                       PosterName: "lafriks",
+                       State:      "opened",
+                       Created:    time.Date(2019, 11, 28, 15, 56, 54, 104000000, time.UTC),
                        Labels: []*base.Label{
                                {
                                        Name: "bug",
@@ -293,10 +296,15 @@ func TestGitlabDownloadRepo(t *testing.T) {
                        Merged:         false,
                        MergedTime:     nil,
                        MergeCommitSHA: "",
+                       Context: gitlabIssueContext{
+                               foreignID:      2,
+                               localID:        4,
+                               IsMergeRequest: true,
+                       },
                },
        }, prs)
 
-       rvs, err := downloader.GetReviews(1)
+       rvs, err := downloader.GetReviews(base.BasicIssueContext(1))
        assert.NoError(t, err)
        assertReviewsEqual(t, []*base.Review{
                {
@@ -313,7 +321,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
                },
        }, rvs)
 
-       rvs, err = downloader.GetReviews(2)
+       rvs, err = downloader.GetReviews(base.BasicIssueContext(2))
        assert.NoError(t, err)
        assertReviewsEqual(t, []*base.Review{
                {
index 2c7fa76146adb0ad6f01e8d34e7bb5862e3eef40..06c944278b431bf8a2b69bbfa3377ae789eacae7 100644 (file)
@@ -228,10 +228,9 @@ func (g *GogsDownloader) getIssues(page int, state string) ([]*base.Issue, bool,
 
 // GetComments returns comments according issueNumber
 func (g *GogsDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
-       var issueNumber = opts.IssueNumber
        var allComments = make([]*base.Comment, 0, 100)
 
-       comments, err := g.client.ListIssueComments(g.repoOwner, g.repoName, issueNumber)
+       comments, err := g.client.ListIssueComments(g.repoOwner, g.repoName, opts.Context.ForeignID())
        if err != nil {
                return nil, false, fmt.Errorf("error while listing repos: %v", err)
        }
@@ -240,7 +239,7 @@ func (g *GogsDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comme
                        continue
                }
                allComments = append(allComments, &base.Comment{
-                       IssueIndex:  issueNumber,
+                       IssueIndex:  opts.Context.LocalID(),
                        PosterID:    comment.Poster.ID,
                        PosterName:  comment.Poster.Login,
                        PosterEmail: comment.Poster.Email,
@@ -304,6 +303,7 @@ func convertGogsIssue(issue *gogs.Issue) *base.Issue {
                Updated:     issue.Updated,
                Labels:      labels,
                Closed:      closed,
+               Context:     base.BasicIssueContext(issue.Index),
        }
 }
 
index e5bd634c550a0bbb87bb7b9107f94916e43ac710..8816fab44fbcbd744ba0c5bcb93262a8768c434b 100644 (file)
@@ -112,7 +112,7 @@ func TestGogsDownloadRepo(t *testing.T) {
 
        // downloader.GetComments()
        comments, _, err := downloader.GetComments(base.GetCommentOptions{
-               IssueNumber: 1,
+               Context: base.BasicIssueContext(1),
        })
        assert.NoError(t, err)
        assertCommentsEqual(t, []*base.Comment{
index 0a507d9c3341be6b810fa089fd8d4fb3fbe77d4d..7d5aa9670b1e9dad91ffee41911c780135597541 100644 (file)
@@ -318,7 +318,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
                                for _, issue := range issues {
                                        log.Trace("migrating issue %d's comments", issue.Number)
                                        comments, _, err := downloader.GetComments(base.GetCommentOptions{
-                                               IssueNumber: issue.Number,
+                                               Context: issue.Context,
                                        })
                                        if err != nil {
                                                if !base.IsErrNotSupported(err) {
@@ -376,7 +376,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
                                        for _, pr := range prs {
                                                log.Trace("migrating pull request %d's comments", pr.Number)
                                                comments, _, err := downloader.GetComments(base.GetCommentOptions{
-                                                       IssueNumber: pr.Number,
+                                                       Context: pr.Context,
                                                })
                                                if err != nil {
                                                        if !base.IsErrNotSupported(err) {
@@ -404,14 +404,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
                                // migrate reviews
                                var allReviews = make([]*base.Review, 0, reviewBatchSize)
                                for _, pr := range prs {
-                                       number := pr.Number
-
-                                       // on gitlab migrations pull number change
-                                       if pr.OriginalNumber > 0 {
-                                               number = pr.OriginalNumber
-                                       }
-
-                                       reviews, err := downloader.GetReviews(number)
+                                       reviews, err := downloader.GetReviews(pr.Context)
                                        if err != nil {
                                                if !base.IsErrNotSupported(err) {
                                                        return err
@@ -419,11 +412,6 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
                                                log.Warn("migrating reviews is not supported, ignored")
                                                break
                                        }
-                                       if pr.OriginalNumber > 0 {
-                                               for i := range reviews {
-                                                       reviews[i].IssueIndex = pr.Number
-                                               }
-                                       }
 
                                        allReviews = append(allReviews, reviews...)
 
diff --git a/modules/migrations/onedev.go b/modules/migrations/onedev.go
new file mode 100644 (file)
index 0000000..e602658
--- /dev/null
@@ -0,0 +1,619 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "net/url"
+       "strconv"
+       "strings"
+       "time"
+
+       "code.gitea.io/gitea/modules/json"
+       "code.gitea.io/gitea/modules/log"
+       "code.gitea.io/gitea/modules/migrations/base"
+       "code.gitea.io/gitea/modules/structs"
+)
+
+var (
+       _ base.Downloader        = &OneDevDownloader{}
+       _ base.DownloaderFactory = &OneDevDownloaderFactory{}
+)
+
+func init() {
+       RegisterDownloaderFactory(&OneDevDownloaderFactory{})
+}
+
+// OneDevDownloaderFactory defines a downloader factory
+type OneDevDownloaderFactory struct {
+}
+
+// New returns a downloader related to this factory according MigrateOptions
+func (f *OneDevDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
+       u, err := url.Parse(opts.CloneAddr)
+       if err != nil {
+               return nil, err
+       }
+
+       repoName := ""
+
+       fields := strings.Split(strings.Trim(u.Path, "/"), "/")
+       if len(fields) == 2 && fields[0] == "projects" {
+               repoName = fields[1]
+       } else if len(fields) == 1 {
+               repoName = fields[0]
+       } else {
+               return nil, fmt.Errorf("invalid path: %s", u.Path)
+       }
+
+       u.Path = ""
+       u.Fragment = ""
+
+       log.Trace("Create onedev downloader. BaseURL: %v RepoName: %s", u, repoName)
+
+       return NewOneDevDownloader(ctx, u, opts.AuthUsername, opts.AuthPassword, repoName), nil
+}
+
+// GitServiceType returns the type of git service
+func (f *OneDevDownloaderFactory) GitServiceType() structs.GitServiceType {
+       return structs.OneDevService
+}
+
+type onedevUser struct {
+       ID    int64  `json:"id"`
+       Name  string `json:"name"`
+       Email string `json:"email"`
+}
+
+// OneDevDownloader implements a Downloader interface to get repository informations
+// from OneDev
+type OneDevDownloader struct {
+       base.NullDownloader
+       ctx           context.Context
+       client        *http.Client
+       baseURL       *url.URL
+       repoName      string
+       repoID        int64
+       maxIssueIndex int64
+       userMap       map[int64]*onedevUser
+       milestoneMap  map[int64]string
+}
+
+// SetContext set context
+func (d *OneDevDownloader) SetContext(ctx context.Context) {
+       d.ctx = ctx
+}
+
+// NewOneDevDownloader creates a new downloader
+func NewOneDevDownloader(ctx context.Context, baseURL *url.URL, username, password, repoName string) *OneDevDownloader {
+       var downloader = &OneDevDownloader{
+               ctx:      ctx,
+               baseURL:  baseURL,
+               repoName: repoName,
+               client: &http.Client{
+                       Transport: &http.Transport{
+                               Proxy: func(req *http.Request) (*url.URL, error) {
+                                       if len(username) > 0 && len(password) > 0 {
+                                               req.SetBasicAuth(username, password)
+                                       }
+                                       return nil, nil
+                               },
+                       },
+               },
+               userMap:      make(map[int64]*onedevUser),
+               milestoneMap: make(map[int64]string),
+       }
+
+       return downloader
+}
+
+func (d *OneDevDownloader) callAPI(endpoint string, parameter map[string]string, result interface{}) error {
+       u, err := d.baseURL.Parse(endpoint)
+       if err != nil {
+               return err
+       }
+
+       if parameter != nil {
+               query := u.Query()
+               for k, v := range parameter {
+                       query.Set(k, v)
+               }
+               u.RawQuery = query.Encode()
+       }
+
+       req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil)
+       if err != nil {
+               return err
+       }
+
+       resp, err := d.client.Do(req)
+       if err != nil {
+               return err
+       }
+       defer resp.Body.Close()
+
+       decoder := json.NewDecoder(resp.Body)
+       return decoder.Decode(&result)
+}
+
+// GetRepoInfo returns repository information
+func (d *OneDevDownloader) GetRepoInfo() (*base.Repository, error) {
+       info := make([]struct {
+               ID          int64  `json:"id"`
+               Name        string `json:"name"`
+               Description string `json:"description"`
+       }, 0, 1)
+
+       err := d.callAPI(
+               "/api/projects",
+               map[string]string{
+                       "query":  `"Name" is "` + d.repoName + `"`,
+                       "offset": "0",
+                       "count":  "1",
+               },
+               &info,
+       )
+       if err != nil {
+               return nil, err
+       }
+       if len(info) != 1 {
+               return nil, fmt.Errorf("Project %s not found", d.repoName)
+       }
+
+       d.repoID = info[0].ID
+
+       cloneURL, err := d.baseURL.Parse(info[0].Name)
+       if err != nil {
+               return nil, err
+       }
+       originalURL, err := d.baseURL.Parse("/projects/" + info[0].Name)
+       if err != nil {
+               return nil, err
+       }
+
+       return &base.Repository{
+               Name:        info[0].Name,
+               Description: info[0].Description,
+               CloneURL:    cloneURL.String(),
+               OriginalURL: originalURL.String(),
+       }, nil
+}
+
+// GetMilestones returns milestones
+func (d *OneDevDownloader) GetMilestones() ([]*base.Milestone, error) {
+       rawMilestones := make([]struct {
+               ID          int64      `json:"id"`
+               Name        string     `json:"name"`
+               Description string     `json:"description"`
+               DueDate     *time.Time `json:"dueDate"`
+               Closed      bool       `json:"closed"`
+       }, 0, 100)
+
+       endpoint := fmt.Sprintf("/api/projects/%d/milestones", d.repoID)
+
+       var milestones = make([]*base.Milestone, 0, 100)
+       offset := 0
+       for {
+               err := d.callAPI(
+                       endpoint,
+                       map[string]string{
+                               "offset": strconv.Itoa(offset),
+                               "count":  "100",
+                       },
+                       &rawMilestones,
+               )
+               if err != nil {
+                       return nil, err
+               }
+               if len(rawMilestones) == 0 {
+                       break
+               }
+               offset += 100
+
+               for _, milestone := range rawMilestones {
+                       d.milestoneMap[milestone.ID] = milestone.Name
+                       closed := milestone.DueDate
+                       if !milestone.Closed {
+                               closed = nil
+                       }
+
+                       milestones = append(milestones, &base.Milestone{
+                               Title:       milestone.Name,
+                               Description: milestone.Description,
+                               Deadline:    milestone.DueDate,
+                               Closed:      closed,
+                       })
+               }
+       }
+       return milestones, nil
+}
+
+// GetLabels returns labels
+func (d *OneDevDownloader) GetLabels() ([]*base.Label, error) {
+       return []*base.Label{
+               {
+                       Name:  "Bug",
+                       Color: "f64e60",
+               },
+               {
+                       Name:  "Build Failure",
+                       Color: "f64e60",
+               },
+               {
+                       Name:  "Discussion",
+                       Color: "8950fc",
+               },
+               {
+                       Name:  "Improvement",
+                       Color: "1bc5bd",
+               },
+               {
+                       Name:  "New Feature",
+                       Color: "1bc5bd",
+               },
+               {
+                       Name:  "Support Request",
+                       Color: "8950fc",
+               },
+       }, nil
+}
+
+type onedevIssueContext struct {
+       foreignID     int64
+       localID       int64
+       IsPullRequest bool
+}
+
+func (c onedevIssueContext) LocalID() int64 {
+       return c.localID
+}
+
+func (c onedevIssueContext) ForeignID() int64 {
+       return c.foreignID
+}
+
+// GetIssues returns issues
+func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
+       rawIssues := make([]struct {
+               ID          int64     `json:"id"`
+               Number      int64     `json:"number"`
+               State       string    `json:"state"`
+               Title       string    `json:"title"`
+               Description string    `json:"description"`
+               MilestoneID int64     `json:"milestoneId"`
+               SubmitterID int64     `json:"submitterId"`
+               SubmitDate  time.Time `json:"submitDate"`
+       }, 0, perPage)
+
+       err := d.callAPI(
+               "/api/issues",
+               map[string]string{
+                       "query":  `"Project" is "` + d.repoName + `"`,
+                       "offset": strconv.Itoa((page - 1) * perPage),
+                       "count":  strconv.Itoa(perPage),
+               },
+               &rawIssues,
+       )
+       if err != nil {
+               return nil, false, err
+       }
+
+       issues := make([]*base.Issue, 0, len(rawIssues))
+       for _, issue := range rawIssues {
+               fields := make([]struct {
+                       Name  string `json:"name"`
+                       Value string `json:"value"`
+               }, 0, 10)
+               err := d.callAPI(
+                       fmt.Sprintf("/api/issues/%d/fields", issue.ID),
+                       nil,
+                       &fields,
+               )
+               if err != nil {
+                       return nil, false, err
+               }
+
+               var label *base.Label
+               for _, field := range fields {
+                       if field.Name == "Type" {
+                               label = &base.Label{Name: field.Value}
+                               break
+                       }
+               }
+
+               state := strings.ToLower(issue.State)
+               if state == "released" {
+                       state = "closed"
+               }
+               poster := d.tryGetUser(issue.SubmitterID)
+               issues = append(issues, &base.Issue{
+                       Title:       issue.Title,
+                       Number:      issue.Number,
+                       PosterName:  poster.Name,
+                       PosterEmail: poster.Email,
+                       Content:     issue.Description,
+                       Milestone:   d.milestoneMap[issue.MilestoneID],
+                       State:       state,
+                       Created:     issue.SubmitDate,
+                       Updated:     issue.SubmitDate,
+                       Labels:      []*base.Label{label},
+                       Context: onedevIssueContext{
+                               foreignID:     issue.ID,
+                               localID:       issue.Number,
+                               IsPullRequest: false,
+                       },
+               })
+
+               if d.maxIssueIndex < issue.Number {
+                       d.maxIssueIndex = issue.Number
+               }
+       }
+
+       return issues, len(issues) == 0, nil
+}
+
+// GetComments returns comments
+func (d *OneDevDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
+       context, ok := opts.Context.(onedevIssueContext)
+       if !ok {
+               return nil, false, fmt.Errorf("unexpected comment context: %+v", opts.Context)
+       }
+
+       rawComments := make([]struct {
+               Date    time.Time `json:"date"`
+               UserID  int64     `json:"userId"`
+               Content string    `json:"content"`
+       }, 0, 100)
+
+       var endpoint string
+       if context.IsPullRequest {
+               endpoint = fmt.Sprintf("/api/pull-requests/%d/comments", context.ForeignID())
+       } else {
+               endpoint = fmt.Sprintf("/api/issues/%d/comments", context.ForeignID())
+       }
+
+       err := d.callAPI(
+               endpoint,
+               nil,
+               &rawComments,
+       )
+       if err != nil {
+               return nil, false, err
+       }
+
+       rawChanges := make([]struct {
+               Date   time.Time              `json:"date"`
+               UserID int64                  `json:"userId"`
+               Data   map[string]interface{} `json:"data"`
+       }, 0, 100)
+
+       if context.IsPullRequest {
+               endpoint = fmt.Sprintf("/api/pull-requests/%d/changes", context.ForeignID())
+       } else {
+               endpoint = fmt.Sprintf("/api/issues/%d/changes", context.ForeignID())
+       }
+
+       err = d.callAPI(
+               endpoint,
+               nil,
+               &rawChanges,
+       )
+       if err != nil {
+               return nil, false, err
+       }
+
+       comments := make([]*base.Comment, 0, len(rawComments)+len(rawChanges))
+       for _, comment := range rawComments {
+               if len(comment.Content) == 0 {
+                       continue
+               }
+               poster := d.tryGetUser(comment.UserID)
+               comments = append(comments, &base.Comment{
+                       IssueIndex:  context.LocalID(),
+                       PosterID:    poster.ID,
+                       PosterName:  poster.Name,
+                       PosterEmail: poster.Email,
+                       Content:     comment.Content,
+                       Created:     comment.Date,
+                       Updated:     comment.Date,
+               })
+       }
+       for _, change := range rawChanges {
+               contentV, ok := change.Data["content"]
+               if !ok {
+                       contentV, ok = change.Data["comment"]
+                       if !ok {
+                               continue
+                       }
+               }
+               content, ok := contentV.(string)
+               if !ok || len(content) == 0 {
+                       continue
+               }
+
+               poster := d.tryGetUser(change.UserID)
+               comments = append(comments, &base.Comment{
+                       IssueIndex:  context.LocalID(),
+                       PosterID:    poster.ID,
+                       PosterName:  poster.Name,
+                       PosterEmail: poster.Email,
+                       Content:     content,
+                       Created:     change.Date,
+                       Updated:     change.Date,
+               })
+       }
+
+       return comments, true, nil
+}
+
+// GetPullRequests returns pull requests
+func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
+       rawPullRequests := make([]struct {
+               ID             int64     `json:"id"`
+               Number         int64     `json:"number"`
+               Title          string    `json:"title"`
+               SubmitterID    int64     `json:"submitterId"`
+               SubmitDate     time.Time `json:"submitDate"`
+               Description    string    `json:"description"`
+               TargetBranch   string    `json:"targetBranch"`
+               SourceBranch   string    `json:"sourceBranch"`
+               BaseCommitHash string    `json:"baseCommitHash"`
+               CloseInfo      *struct {
+                       Date   *time.Time `json:"date"`
+                       Status string     `json:"status"`
+               }
+       }, 0, perPage)
+
+       err := d.callAPI(
+               "/api/pull-requests",
+               map[string]string{
+                       "query":  `"Target Project" is "` + d.repoName + `"`,
+                       "offset": strconv.Itoa((page - 1) * perPage),
+                       "count":  strconv.Itoa(perPage),
+               },
+               &rawPullRequests,
+       )
+       if err != nil {
+               return nil, false, err
+       }
+
+       pullRequests := make([]*base.PullRequest, 0, len(rawPullRequests))
+       for _, pr := range rawPullRequests {
+               var mergePreview struct {
+                       TargetHeadCommitHash string `json:"targetHeadCommitHash"`
+                       HeadCommitHash       string `json:"headCommitHash"`
+                       MergeStrategy        string `json:"mergeStrategy"`
+                       MergeCommitHash      string `json:"mergeCommitHash"`
+               }
+               err := d.callAPI(
+                       fmt.Sprintf("/api/pull-requests/%d/merge-preview", pr.ID),
+                       nil,
+                       &mergePreview,
+               )
+               if err != nil {
+                       return nil, false, err
+               }
+
+               state := "open"
+               merged := false
+               var closeTime *time.Time
+               var mergedTime *time.Time
+               if pr.CloseInfo != nil {
+                       state = "closed"
+                       closeTime = pr.CloseInfo.Date
+                       if pr.CloseInfo.Status == "MERGED" { // "DISCARDED"
+                               merged = true
+                               mergedTime = pr.CloseInfo.Date
+                       }
+               }
+               poster := d.tryGetUser(pr.SubmitterID)
+
+               number := pr.Number + d.maxIssueIndex
+               pullRequests = append(pullRequests, &base.PullRequest{
+                       Title:      pr.Title,
+                       Number:     number,
+                       PosterName: poster.Name,
+                       PosterID:   poster.ID,
+                       Content:    pr.Description,
+                       State:      state,
+                       Created:    pr.SubmitDate,
+                       Updated:    pr.SubmitDate,
+                       Closed:     closeTime,
+                       Merged:     merged,
+                       MergedTime: mergedTime,
+                       Head: base.PullRequestBranch{
+                               Ref:      pr.SourceBranch,
+                               SHA:      mergePreview.HeadCommitHash,
+                               RepoName: d.repoName,
+                       },
+                       Base: base.PullRequestBranch{
+                               Ref:      pr.TargetBranch,
+                               SHA:      mergePreview.TargetHeadCommitHash,
+                               RepoName: d.repoName,
+                       },
+                       Context: onedevIssueContext{
+                               foreignID:     pr.ID,
+                               localID:       number,
+                               IsPullRequest: true,
+                       },
+               })
+       }
+
+       return pullRequests, len(pullRequests) == 0, nil
+}
+
+// GetReviews returns pull requests reviews
+func (d *OneDevDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) {
+       rawReviews := make([]struct {
+               ID     int64 `json:"id"`
+               UserID int64 `json:"userId"`
+               Result *struct {
+                       Commit   string `json:"commit"`
+                       Approved bool   `json:"approved"`
+                       Comment  string `json:"comment"`
+               }
+       }, 0, 100)
+
+       err := d.callAPI(
+               fmt.Sprintf("/api/pull-requests/%d/reviews", context.ForeignID()),
+               nil,
+               &rawReviews,
+       )
+       if err != nil {
+               return nil, err
+       }
+
+       var reviews = make([]*base.Review, 0, len(rawReviews))
+       for _, review := range rawReviews {
+               state := base.ReviewStatePending
+               content := ""
+               if review.Result != nil {
+                       if len(review.Result.Comment) > 0 {
+                               state = base.ReviewStateCommented
+                               content = review.Result.Comment
+                       }
+                       if review.Result.Approved {
+                               state = base.ReviewStateApproved
+                       }
+               }
+
+               poster := d.tryGetUser(review.UserID)
+               reviews = append(reviews, &base.Review{
+                       IssueIndex:   context.LocalID(),
+                       ReviewerID:   poster.ID,
+                       ReviewerName: poster.Name,
+                       Content:      content,
+                       State:        state,
+               })
+       }
+
+       return reviews, nil
+}
+
+// GetTopics return repository topics
+func (d *OneDevDownloader) GetTopics() ([]string, error) {
+       return []string{}, nil
+}
+
+func (d *OneDevDownloader) tryGetUser(userID int64) *onedevUser {
+       user, ok := d.userMap[userID]
+       if !ok {
+               err := d.callAPI(
+                       fmt.Sprintf("/api/users/%d", userID),
+                       nil,
+                       &user,
+               )
+               if err != nil {
+                       user = &onedevUser{
+                               Name: fmt.Sprintf("User %d", userID),
+                       }
+               }
+               d.userMap[userID] = user
+       }
+
+       return user
+}
diff --git a/modules/migrations/onedev_test.go b/modules/migrations/onedev_test.go
new file mode 100644 (file)
index 0000000..48d56c3
--- /dev/null
@@ -0,0 +1,169 @@
+// Copyright 2021 The Gitea Authors. All rights reserved.
+// Use of this source code is governed by a MIT-style
+// license that can be found in the LICENSE file.
+
+package migrations
+
+import (
+       "context"
+       "fmt"
+       "net/http"
+       "net/url"
+       "testing"
+       "time"
+
+       "code.gitea.io/gitea/modules/migrations/base"
+
+       "github.com/stretchr/testify/assert"
+)
+
+func TestOneDevDownloadRepo(t *testing.T) {
+       resp, err := http.Get("https://code.onedev.io/projects/go-gitea-test_repo")
+       if err != nil || resp.StatusCode != 200 {
+               t.Skipf("Can't access test repo, skipping %s", t.Name())
+       }
+
+       u, _ := url.Parse("https://code.onedev.io")
+       downloader := NewOneDevDownloader(context.Background(), u, "", "", "go-gitea-test_repo")
+       if err != nil {
+               t.Fatal(fmt.Sprintf("NewOneDevDownloader is nil: %v", err))
+       }
+       repo, err := downloader.GetRepoInfo()
+       assert.NoError(t, err)
+       assert.EqualValues(t, &base.Repository{
+               Name:        "go-gitea-test_repo",
+               Owner:       "",
+               Description: "Test repository for testing migration from OneDev to gitea",
+               CloneURL:    "https://code.onedev.io/go-gitea-test_repo",
+               OriginalURL: "https://code.onedev.io/projects/go-gitea-test_repo",
+       }, repo)
+
+       milestones, err := downloader.GetMilestones()
+       assert.NoError(t, err)
+       assert.Len(t, milestones, 2)
+       deadline := time.Unix(1620086400, 0)
+       assert.EqualValues(t, []*base.Milestone{
+               {
+                       Title:    "1.0.0",
+                       Deadline: &deadline,
+                       Closed:   &deadline,
+               },
+               {
+                       Title:       "1.1.0",
+                       Description: "next things?",
+               },
+       }, milestones)
+
+       labels, err := downloader.GetLabels()
+       assert.NoError(t, err)
+       assert.Len(t, labels, 6)
+
+       issues, isEnd, err := downloader.GetIssues(1, 2)
+       assert.NoError(t, err)
+       assert.Len(t, issues, 2)
+       assert.False(t, isEnd)
+       assert.EqualValues(t, []*base.Issue{
+               {
+                       Number:     4,
+                       Title:      "Hi there",
+                       Content:    "an issue not assigned to a milestone",
+                       PosterName: "User 336",
+                       State:      "open",
+                       Created:    time.Unix(1628549776, 734000000),
+                       Updated:    time.Unix(1628549776, 734000000),
+                       Labels: []*base.Label{
+                               {
+                                       Name: "Improvement",
+                               },
+                       },
+                       Context: onedevIssueContext{
+                               foreignID:     398,
+                               localID:       4,
+                               IsPullRequest: false,
+                       },
+               },
+               {
+                       Number:     3,
+                       Title:      "Add an awesome feature",
+                       Content:    "just another issue to test against",
+                       PosterName: "User 336",
+                       State:      "open",
+                       Milestone:  "1.1.0",
+                       Created:    time.Unix(1628549749, 878000000),
+                       Updated:    time.Unix(1628549749, 878000000),
+                       Labels: []*base.Label{
+                               {
+                                       Name: "New Feature",
+                               },
+                       },
+                       Context: onedevIssueContext{
+                               foreignID:     397,
+                               localID:       3,
+                               IsPullRequest: false,
+                       },
+               },
+       }, issues)
+
+       comments, _, err := downloader.GetComments(base.GetCommentOptions{
+               Context: onedevIssueContext{
+                       foreignID:     398,
+                       localID:       4,
+                       IsPullRequest: false,
+               },
+       })
+       assert.NoError(t, err)
+       assert.Len(t, comments, 1)
+       assert.EqualValues(t, []*base.Comment{
+               {
+                       IssueIndex: 4,
+                       PosterName: "User 336",
+                       Created:    time.Unix(1628549791, 128000000),
+                       Updated:    time.Unix(1628549791, 128000000),
+                       Content:    "it has a comment\r\n\r\nEDIT: that got edited",
+               },
+       }, comments)
+
+       prs, _, err := downloader.GetPullRequests(1, 1)
+       assert.NoError(t, err)
+       assert.Len(t, prs, 1)
+       assert.EqualValues(t, []*base.PullRequest{
+               {
+                       Number:     5,
+                       Title:      "Pull to add a new file",
+                       Content:    "just do some git stuff",
+                       PosterName: "User 336",
+                       State:      "open",
+                       Created:    time.Unix(1628550076, 25000000),
+                       Updated:    time.Unix(1628550076, 25000000),
+                       Head: base.PullRequestBranch{
+                               Ref:      "branch-for-a-pull",
+                               SHA:      "343deffe3526b9bc84e873743ff7f6e6d8b827c0",
+                               RepoName: "go-gitea-test_repo",
+                       },
+                       Base: base.PullRequestBranch{
+                               Ref:      "master",
+                               SHA:      "f32b0a9dfd09a60f616f29158f772cedd89942d2",
+                               RepoName: "go-gitea-test_repo",
+                       },
+                       Context: onedevIssueContext{
+                               foreignID:     186,
+                               localID:       5,
+                               IsPullRequest: true,
+                       },
+               },
+       }, prs)
+
+       rvs, err := downloader.GetReviews(onedevIssueContext{
+               foreignID: 186,
+               localID:   5,
+       })
+       assert.NoError(t, err)
+       assert.Len(t, rvs, 1)
+       assert.EqualValues(t, []*base.Review{
+               {
+                       IssueIndex:   5,
+                       ReviewerName: "User 317",
+                       State:        "PENDING",
+               },
+       }, rvs)
+}
index 6177f80cbbca4363b35a8f6bc35e92c3e05d5410..6287d601c2a0aca48bcea4641c0fc9ae0f7214cf 100644 (file)
@@ -208,13 +208,16 @@ func (r *RepositoryRestorer) GetIssues(page, perPage int) ([]*base.Issue, bool,
        if err != nil {
                return nil, false, err
        }
+       for _, issue := range issues {
+               issue.Context = base.BasicIssueContext(issue.Number)
+       }
        return issues, true, nil
 }
 
 // GetComments returns comments according issueNumber
 func (r *RepositoryRestorer) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
        var comments = make([]*base.Comment, 0, 10)
-       p := filepath.Join(r.commentDir(), fmt.Sprintf("%d.yml", opts.IssueNumber))
+       p := filepath.Join(r.commentDir(), fmt.Sprintf("%d.yml", opts.Context.ForeignID()))
        _, err := os.Stat(p)
        if err != nil {
                if os.IsNotExist(err) {
@@ -258,14 +261,15 @@ func (r *RepositoryRestorer) GetPullRequests(page, perPage int) ([]*base.PullReq
        }
        for _, pr := range pulls {
                pr.PatchURL = "file://" + filepath.Join(r.baseDir, pr.PatchURL)
+               pr.Context = base.BasicIssueContext(pr.Number)
        }
        return pulls, true, nil
 }
 
 // GetReviews returns pull requests review
-func (r *RepositoryRestorer) GetReviews(pullRequestNumber int64) ([]*base.Review, error) {
+func (r *RepositoryRestorer) GetReviews(context base.IssueContext) ([]*base.Review, error) {
        var reviews = make([]*base.Review, 0, 10)
-       p := filepath.Join(r.reviewDir(), fmt.Sprintf("%d.yml", pullRequestNumber))
+       p := filepath.Join(r.reviewDir(), fmt.Sprintf("%d.yml", context.ForeignID()))
        _, err := os.Stat(p)
        if err != nil {
                if os.IsNotExist(err) {
index 2089f4d69cd0ce0a9345e3af369ef8bb0c16d6d6..313a982f43d63f7829d2a41b82fd30ac5b074c57 100644 (file)
@@ -248,6 +248,7 @@ const (
        GiteaService                          // 3 gitea service
        GitlabService                         // 4 gitlab service
        GogsService                           // 5 gogs service
+       OneDevService                         // 6 onedev service
 )
 
 // Name represents the service type's name
@@ -267,6 +268,8 @@ func (gt GitServiceType) Title() string {
                return "GitLab"
        case GogsService:
                return "Gogs"
+       case OneDevService:
+               return "OneDev"
        case PlainGitService:
                return "Git"
        }
@@ -322,5 +325,6 @@ var (
                GitlabService,
                GiteaService,
                GogsService,
+               OneDevService,
        }
 )
index 4715afcd3e54d78948fb03d8b2365c56e3134587..3eb38257768a4aa3a07b073cc4b9035dde782b65 100644 (file)
@@ -904,6 +904,7 @@ migrate.git.description = Migrating or Mirroring git data from Git services
 migrate.gitlab.description = Migrating data from GitLab.com or Self-Hosted gitlab server.
 migrate.gitea.description = Migrating data from Gitea.com or Self-Hosted Gitea server.
 migrate.gogs.description = Migrating data from notabug.org or other Self-Hosted Gogs server.
+migrate.onedev.description = Migrating data from code.onedev.io or Self-Hosted OneDev server.
 migrate.migrating_git = Migrating Git Data
 migrate.migrating_topics = Migrating Topics
 migrate.migrating_milestones = Migrating Milestones
diff --git a/public/img/svg/gitea-onedev.svg b/public/img/svg/gitea-onedev.svg
new file mode 100644 (file)
index 0000000..1f0d1d8
--- /dev/null
@@ -0,0 +1 @@
+<svg version="1.0" viewBox="0 0 700 700" class="svg gitea-onedev" width="16" height="16" aria-hidden="true"><path d="M315.5 99.6c-29.5 4-55.8 12-81.2 24.8L223 130l-5.2-4c-14.9-11.3-37.6-14.9-55.8-9-19.1 6.3-35.1 22.2-41.1 41-2.7 8.3-3.6 22.9-1.9 31.2 1.5 8 5 16.5 9.1 22.5 3.1 4.7 3.1 4.8 1.4 7.8C106 260 95.1 294.4 92 337.7c-1.1 15.7-.1 40.2 2.1 53l1.1 6.5-4.9 4.4c-2.8 2.3-7.5 7.6-10.6 11.6-19.4 25.5-24.7 57.9-14.4 88.3 9.2 26.9 31.2 48.8 58.4 58.1 20.6 6.9 40.6 7 61.1.1l6.7-2.2 10.5 7.1c45.6 31 92 45.5 146 45.5 33 0 61.6-5.2 91-16.4 67.6-25.8 122.9-81.1 148.4-148.4l2.7-7.2 7.7-3.8c9.1-4.5 21.1-15.7 25.9-24.3 21.1-37.5-1-84.3-43.2-91.7-19.9-3.5-39.3 2.7-53.9 17.2-7.1 7.1-11.7 14.5-15.3 24.7-3.4 9.4-3.8 25.8-.9 35.3 2.8 9.5 8.5 19.3 15.3 26.4 7.2 7.6 7.2 6 0 20.5-8.9 18.1-20.3 34.1-35.2 49.5-34.6 35.7-78.2 56.3-128.3 60.3-42.8 3.4-89.3-8.9-125-33-1.1-.8-1-1.7.8-5.2 12.1-23.6 13.5-53.7 3.9-78-8.7-21.8-27.5-41.6-48.6-51.2-9-4.1-22.7-7.4-34-8.3l-9.1-.7-.8-9.6c-3.5-46.9 13.5-99.8 45.5-141.7 6.5-8.6 24.3-26.7 33.6-34.2 43.8-35.6 101.3-52.8 158.1-47.2 39.9 3.9 79 19.1 110.6 43 16.9 12.8 37.5 34.9 48.6 52l4.3 6.7-3.3 5.2c-2.9 4.7-3.3 6.3-3.6 13.4-.3 7.3-.1 8.6 2.5 13.6 3.2 6.1 10.2 12 16.3 13.9 22.8 6.8 43-16.9 32.6-38.2-3.1-6.4-9.3-12.2-14.7-13.8-2.5-.8-4.1-2.1-5.2-4.3-.9-1.7-3.2-5.8-5.1-9.2l-3.5-6 3.6-5c17.7-24.4 15.8-57.5-4.4-79.4-8-8.6-15.5-13.6-25.9-17.2-19.8-6.8-38.9-4.2-56.5 7.8l-7.8 5.3-15.3-7.4c-27.9-13.4-55-21.3-84-24.4-13.3-1.5-48.1-1.2-60.3.5z"/><path d="M271.8 271.1c-13.9 2.1-30.5 17.3-40.5 37.4-18.3 36.4-13.4 81.5 9.8 91.5 15.2 6.5 34.5-2.7 48.6-23.2 5.5-8 9.7-15.7 9-16.5-.3-.2-2 .3-3.8 1.2-2.4 1.3-5.1 1.6-10.5 1.3-6.1-.3-7.9-.8-11.6-3.4-8.9-6.2-12.4-19.1-7.9-29 2.4-5.2 9-10.8 14.7-12.4 9.1-2.6 20 1.4 25.2 9.2l2.7 4.2.3-12.4c.4-18.9-3.4-31.6-12.4-40.5-6.3-6.3-14.2-8.8-23.6-7.4zM420.5 271c-11.6 1.9-20.2 11.3-24.9 27-2.1 6.9-3.1 20-2.2 27.4l.8 5.7 2.1-3.2c10.2-15 31.6-14 39.9 2 6 11.5 1.5 25.1-10.4 31.2-5 2.5-15 2.6-20 .1l-3.6-1.9 1.4 3.3c6.1 14.5 20 30.1 32.3 36.1 5.7 2.8 14.4 4 20.4 2.9 5.2-1 12.1-6.1 16.1-11.9 18.1-26.4 8.1-79-20-105.8-10.8-10.2-21.6-14.6-31.9-12.9zM322.5 431.9c-16.1 1.6-23.5 6.1-23.5 14.3 0 11.4 13 21.1 34 25.4 10.2 2 31.2 1.5 40.5-1 13.5-3.7 23.8-10.3 27.6-17.7 4.9-9.7-.2-17.1-13.8-20-6.1-1.2-54.2-2-64.8-1z"/></svg>
\ No newline at end of file
diff --git a/templates/repo/migrate/onedev.tmpl b/templates/repo/migrate/onedev.tmpl
new file mode 100644 (file)
index 0000000..def366f
--- /dev/null
@@ -0,0 +1,117 @@
+{{template "base/head" .}}
+<div class="page-content repository new migrate">
+       <div class="ui middle very relaxed page grid">
+               <div class="column">
+                       <form class="ui form" action="{{.Link}}" method="post">
+                               {{.CsrfTokenHtml}}
+                               <h3 class="ui top attached header">
+                                       {{.i18n.Tr "repo.migrate.migrate" .service.Title}}
+                                       <input id="service_type" type="hidden" name="service" value="{{.service}}">
+                               </h3>
+                               <div class="ui attached segment">
+                                       {{template "base/alert" .}}
+                                       <div class="inline required field {{if .Err_CloneAddr}}error{{end}}">
+                                               <label for="clone_addr">{{.i18n.Tr "repo.migrate.clone_address"}}</label>
+                                               <input id="clone_addr" name="clone_addr" value="{{.clone_addr}}" autofocus required>
+                                               <span class="help">
+                                               {{.i18n.Tr "repo.migrate.clone_address_desc"}}{{if .ContextUser.CanImportLocal}} {{.i18n.Tr "repo.migrate.clone_local_path"}}{{end}}
+                                               </span>
+                                       </div>
+
+                                       <div class="inline field {{if .Err_Auth}}error{{end}}">
+                                               <label for="auth_username">{{.i18n.Tr "username"}}</label>
+                                               <input id="auth_username" name="auth_username" value="{{.auth_username}}" {{if not .auth_username}}data-need-clear="true"{{end}}>
+                                       </div>
+                                       <input class="fake" type="password">
+                                       <div class="inline field {{if .Err_Auth}}error{{end}}">
+                                               <label for="auth_password">{{.i18n.Tr "password"}}</label>
+                                               <input id="auth_password" name="auth_password" type="password" value="{{.auth_password}}">
+                                       </div>
+
+                                       {{template "repo/migrate/options" .}}
+
+                                       <div id="migrate_items">
+                                               <div class="inline field">
+                                                       <label>{{.i18n.Tr "repo.migrate_items"}}</label>
+                                                       <div class="ui checkbox">
+                                                               <input name="milestones" type="checkbox" {{if .milestones}}checked{{end}}>
+                                                               <label>{{.i18n.Tr "repo.migrate_items_milestones" | Safe}}</label>
+                                                       </div>
+                                                       <div class="ui checkbox">
+                                                               <input name="labels" type="checkbox" {{if .labels}}checked{{end}}>
+                                                               <label>{{.i18n.Tr "repo.migrate_items_labels" | Safe}}</label>
+                                                       </div>
+                                               </div>
+                                               <div class="inline field">
+                                                       <label></label>
+                                                       <div class="ui checkbox">
+                                                               <input name="issues" type="checkbox" {{if .issues}}checked{{end}}>
+                                                               <label>{{.i18n.Tr "repo.migrate_items_issues" | Safe}}</label>
+                                                       </div>
+                                                       <div class="ui checkbox">
+                                                               <input name="pull_requests" type="checkbox" {{if .pull_requests}}checked{{end}}>
+                                                               <label>{{.i18n.Tr "repo.migrate_items_pullrequests" | Safe}}</label>
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="ui divider"></div>
+
+                                       <div class="inline required field {{if .Err_Owner}}error{{end}}">
+                                               <label>{{.i18n.Tr "repo.owner"}}</label>
+                                               <div class="ui selection owner dropdown">
+                                                       <input type="hidden" id="uid" name="uid" value="{{.ContextUser.ID}}" required>
+                                                       <span class="text truncated-item-container" title="{{.ContextUser.Name}}">
+                                                               {{avatar .ContextUser 28 "mini"}}
+                                                               <span class="truncated-item-name">{{.ContextUser.ShortName 40}}</span>
+                                                       </span>
+                                                       {{svg "octicon-triangle-down" 14 "dropdown icon"}}
+                                                       <div class="menu" title="{{.SignedUser.Name}}">
+                                                               <div class="item truncated-item-container" data-value="{{.SignedUser.ID}}">
+                                                                       {{avatar .SignedUser 28 "mini"}}
+                                                                       <span class="truncated-item-name">{{.SignedUser.ShortName 40}}</span>
+                                                               </div>
+                                                               {{range .Orgs}}
+                                                                       <div class="item truncated-item-container" data-value="{{.ID}}" title="{{.Name}}">
+                                                                               {{avatar . 28 "mini"}}
+                                                                               <span class="truncated-item-name">{{.ShortName 40}}</span>
+                                                                       </div>
+                                                               {{end}}
+                                                       </div>
+                                               </div>
+                                       </div>
+
+                                       <div class="inline required field {{if .Err_RepoName}}error{{end}}">
+                                               <label for="repo_name">{{.i18n.Tr "repo.repo_name"}}</label>
+                                               <input id="repo_name" name="repo_name" value="{{.repo_name}}" required>
+                                       </div>
+                                       <div class="inline field">
+                                               <label>{{.i18n.Tr "repo.visibility"}}</label>
+                                               <div class="ui checkbox">
+                                                       {{if .IsForcedPrivate}}
+                                                               <input name="private" type="checkbox" checked readonly>
+                                                               <label>{{.i18n.Tr "repo.visibility_helper_forced" | Safe}}</label>
+                                                       {{else}}
+                                                               <input name="private" type="checkbox" {{if .private}}checked{{end}}>
+                                                               <label>{{.i18n.Tr "repo.visibility_helper" | Safe}}</label>
+                                                       {{end}}
+                                               </div>
+                                       </div>
+                                       <div class="inline field {{if .Err_Description}}error{{end}}">
+                                               <label for="description">{{.i18n.Tr "repo.repo_desc"}}</label>
+                                               <textarea id="description" name="description">{{.description}}</textarea>
+                                       </div>
+
+                                       <div class="inline field">
+                                               <label></label>
+                                               <button class="ui green button">
+                                                       {{.i18n.Tr "repo.migrate_repo"}}
+                                               </button>
+                                               <a class="ui button" href="{{AppSubUrl}}/">{{.i18n.Tr "cancel"}}</a>
+                                       </div>
+                               </div>
+                       </form>
+               </div>
+       </div>
+</div>
+{{template "base/footer" .}}
diff --git a/web_src/svg/gitea-onedev.svg b/web_src/svg/gitea-onedev.svg
new file mode 100644 (file)
index 0000000..490c22f
--- /dev/null
@@ -0,0 +1,42 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
+ preserveAspectRatio="xMidYMid meet">
+
+<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M3155 6004 c-295 -40 -558 -120 -812 -248 l-113 -56 -52 40 c-149
+113 -376 149 -558 90 -191 -63 -351 -222 -411 -410 -27 -83 -36 -229 -19 -312
+15 -80 50 -165 91 -225 31 -47 31 -48 14 -78 -235 -405 -344 -749 -375 -1182
+-11 -157 -1 -402 21 -530 l11 -65 -49 -44 c-28 -23 -75 -76 -106 -116 -194
+-255 -247 -579 -144 -883 92 -269 312 -488 584 -581 206 -69 406 -70 611 -1
+l67 22 105 -71 c456 -310 920 -455 1460 -455 330 0 616 52 910 164 676 258
+1229 811 1484 1484 l27 72 77 38 c91 45 211 157 259 243 211 375 -10 843 -432
+917 -199 35 -393 -27 -539 -172 -71 -71 -117 -145 -153 -247 -34 -94 -38 -258
+-9 -353 28 -95 85 -193 153 -264 72 -76 72 -60 0 -205 -89 -181 -203 -341
+-352 -495 -346 -357 -782 -563 -1283 -603 -428 -34 -893 89 -1250 330 -11 8
+-10 17 8 52 121 236 135 537 39 780 -87 218 -275 416 -486 512 -90 41 -227 74
+-340 83 l-91 7 -8 96 c-35 469 135 998 455 1417 65 86 243 267 336 342 438
+356 1013 528 1581 472 399 -39 790 -191 1106 -430 169 -128 375 -349 486 -520
+l43 -67 -33 -52 c-29 -47 -33 -63 -36 -134 -3 -73 -1 -86 25 -136 32 -61 102
+-120 163 -139 228 -68 430 169 326 382 -31 64 -93 122 -147 138 -25 8 -41 21
+-52 43 -9 17 -32 58 -51 92 l-35 60 36 50 c177 244 158 575 -44 794 -80 86
+-155 136 -259 172 -198 68 -389 42 -565 -78 l-78 -53 -153 74 c-279 134 -550
+213 -840 244 -133 15 -481 12 -603 -5z"/>
+<path d="M2718 4289 c-139 -21 -305 -173 -405 -374 -183 -364 -134 -815 98
+-915 152 -65 345 27 486 232 55 80 97 157 90 165 -3 2 -20 -3 -38 -12 -24 -13
+-51 -16 -105 -13 -61 3 -79 8 -116 34 -89 62 -124 191 -79 290 24 52 90 108
+147 124 91 26 200 -14 252 -92 l27 -42 3 124 c4 189 -34 316 -124 405 -63 63
+-142 88 -236 74z"/>
+<path d="M4205 4290 c-116 -19 -202 -113 -249 -270 -21 -69 -31 -200 -22 -274
+l8 -57 21 32 c102 150 316 140 399 -20 60 -115 15 -251 -104 -312 -50 -25
+-150 -26 -200 -1 l-36 19 14 -33 c61 -145 200 -301 323 -361 57 -28 144 -40
+204 -29 52 10 121 61 161 119 181 264 81 790 -200 1058 -108 102 -216 146
+-319 129z"/>
+<path d="M3225 2681 c-161 -16 -235 -61 -235 -143 0 -114 130 -211 340 -254
+102 -20 312 -15 405 10 135 37 238 103 276 177 49 97 -2 171 -138 200 -61 12
+-542 20 -648 10z"/>
+</g>
+</svg>
\ No newline at end of file