Add migrate from OneDev (#16356)

* 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
This commit is contained in:
KN4CK3R 2021-08-22 00:47:45 +02:00 committed by GitHub
parent 2d1935acc7
commit cee5f7c5e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1093 additions and 92 deletions

View File

@ -33,6 +33,8 @@ func ToGitServiceType(value string) structs.GitServiceType {
return structs.GitlabService return structs.GitlabService
case "gogs": case "gogs":
return structs.GogsService return structs.GogsService
case "onedev":
return structs.OneDevService
default: default:
return structs.PlainGitService return structs.PlainGitService
} }

View File

@ -13,9 +13,9 @@ import (
// GetCommentOptions represents an options for get comment // GetCommentOptions represents an options for get comment
type GetCommentOptions struct { type GetCommentOptions struct {
IssueNumber int64 Context IssueContext
Page int Page int
PageSize int PageSize int
} }
// Downloader downloads the site repo information // Downloader downloads the site repo information
@ -30,7 +30,7 @@ type Downloader interface {
GetComments(opts GetCommentOptions) ([]*Comment, bool, error) GetComments(opts GetCommentOptions) ([]*Comment, bool, error)
SupportGetRepoComments() bool SupportGetRepoComments() bool
GetPullRequests(page, perPage int) ([]*PullRequest, bool, error) GetPullRequests(page, perPage int) ([]*PullRequest, bool, error)
GetReviews(pullRequestNumber int64) ([]*Review, error) GetReviews(pullRequestContext IssueContext) ([]*Review, error)
FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error) FormatCloneURL(opts MigrateOptions, remoteAddr string) (string, error)
} }

View File

@ -7,6 +7,25 @@ package base
import "time" 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 // Issue is a standard issue information
type Issue struct { type Issue struct {
Number int64 Number int64
@ -25,4 +44,5 @@ type Issue struct {
Labels []*Label Labels []*Label
Reactions []*Reaction Reactions []*Reaction
Assignees []string Assignees []string
Context IssueContext `yaml:"-"`
} }

View File

@ -50,7 +50,7 @@ func (n NullDownloader) GetIssues(page, perPage int) ([]*Issue, bool, error) {
return nil, false, &ErrNotSupported{Entity: "Issues"} 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) { func (n NullDownloader) GetComments(GetCommentOptions) ([]*Comment, bool, error) {
return nil, false, &ErrNotSupported{Entity: "Comments"} return nil, false, &ErrNotSupported{Entity: "Comments"}
} }
@ -61,7 +61,7 @@ func (n NullDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bool
} }
// GetReviews returns pull requests review // 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"} return nil, &ErrNotSupported{Entity: "Reviews"}
} }

View File

@ -13,7 +13,6 @@ import (
// PullRequest defines a standard pull request information // PullRequest defines a standard pull request information
type PullRequest struct { type PullRequest struct {
Number int64 Number int64
OriginalNumber int64 `yaml:"original_number"`
Title string Title string
PosterName string `yaml:"poster_name"` PosterName string `yaml:"poster_name"`
PosterID int64 `yaml:"poster_id"` PosterID int64 `yaml:"poster_id"`
@ -34,6 +33,7 @@ type PullRequest struct {
Assignees []string Assignees []string
IsLocked bool `yaml:"is_locked"` IsLocked bool `yaml:"is_locked"`
Reactions []*Reaction Reactions []*Reaction
Context IssueContext `yaml:"-"`
} }
// IsForkPullRequest returns true if the pull request from a forked repository but not the same repository // IsForkPullRequest returns true if the pull request from a forked repository but not the same repository

View File

@ -182,14 +182,14 @@ func (d *RetryDownloader) GetPullRequests(page, perPage int) ([]*PullRequest, bo
} }
// GetReviews returns pull requests reviews // GetReviews returns pull requests reviews
func (d *RetryDownloader) GetReviews(pullRequestNumber int64) ([]*Review, error) { func (d *RetryDownloader) GetReviews(pullRequestContext IssueContext) ([]*Review, error) {
var ( var (
reviews []*Review reviews []*Review
err error err error
) )
err = d.retry(func() error { err = d.retry(func() error {
reviews, err = d.Downloader.GetReviews(pullRequestNumber) reviews, err = d.Downloader.GetReviews(pullRequestContext)
return err return err
}) })

View File

@ -444,6 +444,7 @@ func (g *GiteaDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, err
Labels: labels, Labels: labels,
Assignees: assignees, Assignees: assignees,
IsLocked: issue.IsLocked, IsLocked: issue.IsLocked,
Context: base.BasicIssueContext(issue.Index),
}) })
} }
@ -466,26 +467,26 @@ func (g *GiteaDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comm
default: 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, // PageSize: g.maxPerPage,
// Page: i, // Page: i,
}}) }})
if err != nil { 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 { for _, comment := range comments {
reactions, err := g.getCommentReactions(comment.ID) reactions, err := g.getCommentReactions(comment.ID)
if err != nil { 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( 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) log.Error("create repository notice failed: ", err2)
} }
} }
allComments = append(allComments, &base.Comment{ allComments = append(allComments, &base.Comment{
IssueIndex: opts.IssueNumber, IssueIndex: opts.Context.LocalID(),
PosterID: comment.Poster.ID, PosterID: comment.Poster.ID,
PosterName: comment.Poster.UserName, PosterName: comment.Poster.UserName,
PosterEmail: comment.Poster.Email, PosterEmail: comment.Poster.Email,
@ -615,6 +616,7 @@ func (g *GiteaDownloader) GetPullRequests(page, perPage int) ([]*base.PullReques
RepoName: g.repoName, RepoName: g.repoName,
OwnerName: g.repoOwner, 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 // 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 { if err := g.client.CheckServerVersionConstraint(">=1.12"); err != nil {
log.Info("GiteaDownloader: instance to old, skip GetReviews") log.Info("GiteaDownloader: instance to old, skip GetReviews")
return nil, nil return nil, nil
@ -642,7 +644,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
default: 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, Page: i,
PageSize: g.maxPerPage, PageSize: g.maxPerPage,
}}) }})
@ -652,7 +654,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
for _, pr := range prl { 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 { if err != nil {
return nil, err return nil, err
} }
@ -678,7 +680,7 @@ func (g *GiteaDownloader) GetReviews(index int64) ([]*base.Review, error) {
allReviews = append(allReviews, &base.Review{ allReviews = append(allReviews, &base.Review{
ID: pr.ID, ID: pr.ID,
IssueIndex: index, IssueIndex: context.LocalID(),
ReviewerID: pr.Reviewer.ID, ReviewerID: pr.Reviewer.ID,
ReviewerName: pr.Reviewer.UserName, ReviewerName: pr.Reviewer.UserName,
Official: pr.Official, Official: pr.Official,

View File

@ -199,7 +199,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
}, issues) }, issues)
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: 4, Context: base.BasicIssueContext(4),
}) })
assert.NoError(t, err) assert.NoError(t, err)
assertCommentsEqual(t, []*base.Comment{ assertCommentsEqual(t, []*base.Comment{
@ -265,7 +265,7 @@ func TestGiteaDownloadRepo(t *testing.T) {
PatchURL: "https://gitea.com/gitea/test_repo/pulls/12.patch", PatchURL: "https://gitea.com/gitea/test_repo/pulls/12.patch",
}, prs[1]) }, prs[1])
reviews, err := downloader.GetReviews(7) reviews, err := downloader.GetReviews(base.BasicIssueContext(7))
assert.NoError(t, err) assert.NoError(t, err)
assertReviewsEqual(t, []*base.Review{ assertReviewsEqual(t, []*base.Review{
{ {

View File

@ -609,6 +609,9 @@ func (g *GiteaLocalUploader) newPullRequest(pr *base.PullRequest) (*models.PullR
// download patch file // download patch file
err := func() error { err := func() error {
if pr.PatchURL == "" {
return nil
}
// pr.PatchURL maybe a local file // pr.PatchURL maybe a local file
ret, err := uri.Open(pr.PatchURL) ret, err := uri.Open(pr.PatchURL)
if err != nil { if err != nil {

View File

@ -428,6 +428,7 @@ func (g *GithubDownloaderV3) GetIssues(page, perPage int) ([]*base.Issue, bool,
Closed: issue.ClosedAt, Closed: issue.ClosedAt,
IsLocked: issue.GetLocked(), IsLocked: issue.GetLocked(),
Assignees: assignees, Assignees: assignees,
Context: base.BasicIssueContext(*issue.Number),
}) })
} }
@ -441,15 +442,15 @@ func (g *GithubDownloaderV3) SupportGetRepoComments() bool {
// GetComments returns comments according issueNumber // GetComments returns comments according issueNumber
func (g *GithubDownloaderV3) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) { func (g *GithubDownloaderV3) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
if opts.IssueNumber > 0 { if opts.Context != nil {
comments, err := g.getComments(opts.IssueNumber) comments, err := g.getComments(opts.Context)
return comments, false, err return comments, false, err
} }
return g.GetAllComments(opts.Page, opts.PageSize) 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 ( var (
allComments = make([]*base.Comment, 0, g.maxPerPage) allComments = make([]*base.Comment, 0, g.maxPerPage)
created = "created" created = "created"
@ -464,7 +465,7 @@ func (g *GithubDownloaderV3) getComments(issueNumber int64) ([]*base.Comment, er
} }
for { for {
g.sleep() 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 { if err != nil {
return nil, fmt.Errorf("error while listing repos: %v", err) 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{ allComments = append(allComments, &base.Comment{
IssueIndex: issueNumber, IssueIndex: issueContext.LocalID(),
PosterID: comment.GetUser().GetID(), PosterID: comment.GetUser().GetID(),
PosterName: comment.GetUser().GetLogin(), PosterName: comment.GetUser().GetLogin(),
PosterEmail: comment.GetUser().GetEmail(), PosterEmail: comment.GetUser().GetEmail(),
@ -661,6 +662,7 @@ func (g *GithubDownloaderV3) GetPullRequests(page, perPage int) ([]*base.PullReq
}, },
PatchURL: pr.GetPatchURL(), PatchURL: pr.GetPatchURL(),
Reactions: reactions, Reactions: reactions,
Context: base.BasicIssueContext(*pr.Number),
}) })
} }
@ -724,28 +726,28 @@ func (g *GithubDownloaderV3) convertGithubReviewComments(cs []*github.PullReques
} }
// GetReviews returns pull requests review // 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) var allReviews = make([]*base.Review, 0, g.maxPerPage)
opt := &github.ListOptions{ opt := &github.ListOptions{
PerPage: g.maxPerPage, PerPage: g.maxPerPage,
} }
for { for {
g.sleep() 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 { if err != nil {
return nil, fmt.Errorf("error while listing repos: %v", err) return nil, fmt.Errorf("error while listing repos: %v", err)
} }
g.rate = &resp.Rate g.rate = &resp.Rate
for _, review := range reviews { for _, review := range reviews {
r := convertGithubReview(review) r := convertGithubReview(review)
r.IssueIndex = pullRequestNumber r.IssueIndex = context.LocalID()
// retrieve all review comments // retrieve all review comments
opt2 := &github.ListOptions{ opt2 := &github.ListOptions{
PerPage: g.maxPerPage, PerPage: g.maxPerPage,
} }
for { for {
g.sleep() 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 { if err != nil {
return nil, fmt.Errorf("error while listing repos: %v", err) return nil, fmt.Errorf("error while listing repos: %v", err)
} }

View File

@ -216,7 +216,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
// downloader.GetComments() // downloader.GetComments()
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: 2, Context: base.BasicIssueContext(2),
}) })
assert.NoError(t, err) assert.NoError(t, err)
assertCommentsEqual(t, []*base.Comment{ assertCommentsEqual(t, []*base.Comment{
@ -286,6 +286,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
Merged: true, Merged: true,
MergedTime: timePtr(time.Date(2019, 11, 12, 21, 39, 27, 0, time.UTC)), MergedTime: timePtr(time.Date(2019, 11, 12, 21, 39, 27, 0, time.UTC)),
MergeCommitSHA: "f32b0a9dfd09a60f616f29158f772cedd89942d2", MergeCommitSHA: "f32b0a9dfd09a60f616f29158f772cedd89942d2",
Context: base.BasicIssueContext(3),
}, },
{ {
Number: 4, Number: 4,
@ -332,10 +333,11 @@ func TestGitHubDownloadRepo(t *testing.T) {
Content: "+1", Content: "+1",
}, },
}, },
Context: base.BasicIssueContext(4),
}, },
}, prs) }, prs)
reviews, err := downloader.GetReviews(3) reviews, err := downloader.GetReviews(base.BasicIssueContext(3))
assert.NoError(t, err) assert.NoError(t, err)
assertReviewsEqual(t, []*base.Review{ assertReviewsEqual(t, []*base.Review{
{ {
@ -367,7 +369,7 @@ func TestGitHubDownloadRepo(t *testing.T) {
}, },
}, reviews) }, reviews)
reviews, err = downloader.GetReviews(4) reviews, err = downloader.GetReviews(base.BasicIssueContext(4))
assert.NoError(t, err) assert.NoError(t, err)
assertReviewsEqual(t, []*base.Review{ assertReviewsEqual(t, []*base.Review{
{ {

View File

@ -63,17 +63,14 @@ func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType {
// from gitlab via go-gitlab // from gitlab via go-gitlab
// - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap, // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap,
// because Gitlab has individual Issue and Pull Request numbers. // 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 { type GitlabDownloader struct {
base.NullDownloader base.NullDownloader
ctx context.Context ctx context.Context
client *gitlab.Client client *gitlab.Client
repoID int repoID int
repoName string repoName string
issueCount int64 issueCount int64
fetchPRcomments bool maxPerPage int
maxPerPage int
} }
// NewGitlabDownloader creates a gitlab Downloader via gitlab API // NewGitlabDownloader creates a gitlab Downloader via gitlab API
@ -364,6 +361,20 @@ func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
return releases, nil 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 // GetIssues returns issues according start and limit
// Note: issue label description and colors are not supported by the go-gitlab library at this time // 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) { 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, Closed: issue.ClosedAt,
IsLocked: issue.DiscussionLocked, IsLocked: issue.DiscussionLocked,
Updated: *issue.UpdatedAt, Updated: *issue.UpdatedAt,
Context: gitlabIssueContext{
foreignID: int64(issue.IID),
localID: int64(issue.IID),
IsMergeRequest: false,
},
}) })
// increment issueCount, to be used in GetPullRequests() // 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 // GetComments returns comments according issueNumber
// TODO: figure out how to transfer comment reactions // TODO: figure out how to transfer comment reactions
func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) { 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 allComments = make([]*base.Comment, 0, g.maxPerPage)
var page = 1 var page = 1
var realIssueNumber int64
for { for {
var comments []*gitlab.Discussion var comments []*gitlab.Discussion
var resp *gitlab.Response var resp *gitlab.Response
var err error var err error
// fetchPRcomments decides whether to fetch Issue or PR comments if !context.IsMergeRequest {
if !g.fetchPRcomments { comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListIssueDiscussionsOptions{
realIssueNumber = issueNumber
comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(realIssueNumber), &gitlab.ListIssueDiscussionsOptions{
Page: page, Page: page,
PerPage: g.maxPerPage, PerPage: g.maxPerPage,
}, nil, gitlab.WithContext(g.ctx)) }, nil, gitlab.WithContext(g.ctx))
} else { } else {
// If this is a PR, we need to figure out the Gitlab/original PR ID to be passed below comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(context.ForeignID()), &gitlab.ListMergeRequestDiscussionsOptions{
realIssueNumber = issueNumber - g.issueCount
comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(realIssueNumber), &gitlab.ListMergeRequestDiscussionsOptions{
Page: page, Page: page,
PerPage: g.maxPerPage, PerPage: g.maxPerPage,
}, nil, gitlab.WithContext(g.ctx)) }, nil, gitlab.WithContext(g.ctx))
@ -479,7 +494,7 @@ func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Com
if !comment.IndividualNote { if !comment.IndividualNote {
for _, note := range comment.Notes { for _, note := range comment.Notes {
allComments = append(allComments, &base.Comment{ allComments = append(allComments, &base.Comment{
IssueIndex: realIssueNumber, IssueIndex: context.LocalID(),
PosterID: int64(note.Author.ID), PosterID: int64(note.Author.ID),
PosterName: note.Author.Username, PosterName: note.Author.Username,
PosterEmail: note.Author.Email, PosterEmail: note.Author.Email,
@ -490,7 +505,7 @@ func (g *GitlabDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Com
} else { } else {
c := comment.Notes[0] c := comment.Notes[0]
allComments = append(allComments, &base.Comment{ allComments = append(allComments, &base.Comment{
IssueIndex: realIssueNumber, IssueIndex: context.LocalID(),
PosterID: int64(c.Author.ID), PosterID: int64(c.Author.ID),
PosterName: c.Author.Username, PosterName: c.Author.Username,
PosterEmail: c.Author.Email, 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) var allPRs = make([]*base.PullRequest, 0, perPage)
prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx)) 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{ allPRs = append(allPRs, &base.PullRequest{
Title: pr.Title, Title: pr.Title,
Number: newPRNumber, Number: newPRNumber,
OriginalNumber: int64(pr.IID),
PosterName: pr.Author.Username, PosterName: pr.Author.Username,
PosterID: int64(pr.Author.ID), PosterID: int64(pr.Author.ID),
Content: pr.Description, Content: pr.Description,
@ -615,6 +626,11 @@ func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullReque
OwnerName: pr.Author.Username, OwnerName: pr.Author.Username,
}, },
PatchURL: pr.WebURL + ".patch", 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 // GetReviews returns pull requests review
func (g *GitlabDownloader) GetReviews(pullRequestNumber int64) ([]*base.Review, error) { func (g *GitlabDownloader) GetReviews(context base.IssueContext) ([]*base.Review, error) {
approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(pullRequestNumber), gitlab.WithContext(g.ctx)) approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(context.ForeignID()), gitlab.WithContext(g.ctx))
if err != nil { if err != nil {
if resp != nil && resp.StatusCode == 404 { if resp != nil && resp.StatusCode == 404 {
log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error())) 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)) var reviews = make([]*base.Review, 0, len(approvals.ApprovedBy))
for _, user := range approvals.ApprovedBy { for _, user := range approvals.ApprovedBy {
reviews = append(reviews, &base.Review{ reviews = append(reviews, &base.Review{
IssueIndex: context.LocalID(),
ReviewerID: int64(user.User.ID), ReviewerID: int64(user.User.ID),
ReviewerName: user.User.Username, ReviewerName: user.User.Username,
CreatedAt: *approvals.UpdatedAt, CreatedAt: *approvals.UpdatedAt,

View File

@ -210,7 +210,11 @@ func TestGitlabDownloadRepo(t *testing.T) {
}, issues) }, issues)
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: 2, Context: gitlabIssueContext{
foreignID: 2,
localID: 2,
IsMergeRequest: false,
},
}) })
assert.NoError(t, err) assert.NoError(t, err)
assertCommentsEqual(t, []*base.Comment{ assertCommentsEqual(t, []*base.Comment{
@ -252,15 +256,14 @@ func TestGitlabDownloadRepo(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assertPullRequestsEqual(t, []*base.PullRequest{ assertPullRequestsEqual(t, []*base.PullRequest{
{ {
Number: 4, Number: 4,
OriginalNumber: 2, Title: "Test branch",
Title: "Test branch", Content: "do not merge this PR",
Content: "do not merge this PR", Milestone: "1.0.0",
Milestone: "1.0.0", PosterID: 1241334,
PosterID: 1241334, PosterName: "lafriks",
PosterName: "lafriks", State: "opened",
State: "opened", Created: time.Date(2019, 11, 28, 15, 56, 54, 104000000, time.UTC),
Created: time.Date(2019, 11, 28, 15, 56, 54, 104000000, time.UTC),
Labels: []*base.Label{ Labels: []*base.Label{
{ {
Name: "bug", Name: "bug",
@ -293,10 +296,15 @@ func TestGitlabDownloadRepo(t *testing.T) {
Merged: false, Merged: false,
MergedTime: nil, MergedTime: nil,
MergeCommitSHA: "", MergeCommitSHA: "",
Context: gitlabIssueContext{
foreignID: 2,
localID: 4,
IsMergeRequest: true,
},
}, },
}, prs) }, prs)
rvs, err := downloader.GetReviews(1) rvs, err := downloader.GetReviews(base.BasicIssueContext(1))
assert.NoError(t, err) assert.NoError(t, err)
assertReviewsEqual(t, []*base.Review{ assertReviewsEqual(t, []*base.Review{
{ {
@ -313,7 +321,7 @@ func TestGitlabDownloadRepo(t *testing.T) {
}, },
}, rvs) }, rvs)
rvs, err = downloader.GetReviews(2) rvs, err = downloader.GetReviews(base.BasicIssueContext(2))
assert.NoError(t, err) assert.NoError(t, err)
assertReviewsEqual(t, []*base.Review{ assertReviewsEqual(t, []*base.Review{
{ {

View File

@ -228,10 +228,9 @@ func (g *GogsDownloader) getIssues(page int, state string) ([]*base.Issue, bool,
// GetComments returns comments according issueNumber // GetComments returns comments according issueNumber
func (g *GogsDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) { func (g *GogsDownloader) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
var issueNumber = opts.IssueNumber
var allComments = make([]*base.Comment, 0, 100) 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 { if err != nil {
return nil, false, fmt.Errorf("error while listing repos: %v", err) 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 continue
} }
allComments = append(allComments, &base.Comment{ allComments = append(allComments, &base.Comment{
IssueIndex: issueNumber, IssueIndex: opts.Context.LocalID(),
PosterID: comment.Poster.ID, PosterID: comment.Poster.ID,
PosterName: comment.Poster.Login, PosterName: comment.Poster.Login,
PosterEmail: comment.Poster.Email, PosterEmail: comment.Poster.Email,
@ -304,6 +303,7 @@ func convertGogsIssue(issue *gogs.Issue) *base.Issue {
Updated: issue.Updated, Updated: issue.Updated,
Labels: labels, Labels: labels,
Closed: closed, Closed: closed,
Context: base.BasicIssueContext(issue.Index),
} }
} }

View File

@ -112,7 +112,7 @@ func TestGogsDownloadRepo(t *testing.T) {
// downloader.GetComments() // downloader.GetComments()
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: 1, Context: base.BasicIssueContext(1),
}) })
assert.NoError(t, err) assert.NoError(t, err)
assertCommentsEqual(t, []*base.Comment{ assertCommentsEqual(t, []*base.Comment{

View File

@ -318,7 +318,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
for _, issue := range issues { for _, issue := range issues {
log.Trace("migrating issue %d's comments", issue.Number) log.Trace("migrating issue %d's comments", issue.Number)
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: issue.Number, Context: issue.Context,
}) })
if err != nil { if err != nil {
if !base.IsErrNotSupported(err) { if !base.IsErrNotSupported(err) {
@ -376,7 +376,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
for _, pr := range prs { for _, pr := range prs {
log.Trace("migrating pull request %d's comments", pr.Number) log.Trace("migrating pull request %d's comments", pr.Number)
comments, _, err := downloader.GetComments(base.GetCommentOptions{ comments, _, err := downloader.GetComments(base.GetCommentOptions{
IssueNumber: pr.Number, Context: pr.Context,
}) })
if err != nil { if err != nil {
if !base.IsErrNotSupported(err) { if !base.IsErrNotSupported(err) {
@ -404,14 +404,7 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
// migrate reviews // migrate reviews
var allReviews = make([]*base.Review, 0, reviewBatchSize) var allReviews = make([]*base.Review, 0, reviewBatchSize)
for _, pr := range prs { for _, pr := range prs {
number := pr.Number reviews, err := downloader.GetReviews(pr.Context)
// on gitlab migrations pull number change
if pr.OriginalNumber > 0 {
number = pr.OriginalNumber
}
reviews, err := downloader.GetReviews(number)
if err != nil { if err != nil {
if !base.IsErrNotSupported(err) { if !base.IsErrNotSupported(err) {
return err return err
@ -419,11 +412,6 @@ func migrateRepository(downloader base.Downloader, uploader base.Uploader, opts
log.Warn("migrating reviews is not supported, ignored") log.Warn("migrating reviews is not supported, ignored")
break break
} }
if pr.OriginalNumber > 0 {
for i := range reviews {
reviews[i].IssueIndex = pr.Number
}
}
allReviews = append(allReviews, reviews...) allReviews = append(allReviews, reviews...)

View File

@ -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
}

View File

@ -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)
}

View File

@ -208,13 +208,16 @@ func (r *RepositoryRestorer) GetIssues(page, perPage int) ([]*base.Issue, bool,
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
for _, issue := range issues {
issue.Context = base.BasicIssueContext(issue.Number)
}
return issues, true, nil return issues, true, nil
} }
// GetComments returns comments according issueNumber // GetComments returns comments according issueNumber
func (r *RepositoryRestorer) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) { func (r *RepositoryRestorer) GetComments(opts base.GetCommentOptions) ([]*base.Comment, bool, error) {
var comments = make([]*base.Comment, 0, 10) 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) _, err := os.Stat(p)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -258,14 +261,15 @@ func (r *RepositoryRestorer) GetPullRequests(page, perPage int) ([]*base.PullReq
} }
for _, pr := range pulls { for _, pr := range pulls {
pr.PatchURL = "file://" + filepath.Join(r.baseDir, pr.PatchURL) pr.PatchURL = "file://" + filepath.Join(r.baseDir, pr.PatchURL)
pr.Context = base.BasicIssueContext(pr.Number)
} }
return pulls, true, nil return pulls, true, nil
} }
// GetReviews returns pull requests review // 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) 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) _, err := os.Stat(p)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {

View File

@ -248,6 +248,7 @@ const (
GiteaService // 3 gitea service GiteaService // 3 gitea service
GitlabService // 4 gitlab service GitlabService // 4 gitlab service
GogsService // 5 gogs service GogsService // 5 gogs service
OneDevService // 6 onedev service
) )
// Name represents the service type's name // Name represents the service type's name
@ -267,6 +268,8 @@ func (gt GitServiceType) Title() string {
return "GitLab" return "GitLab"
case GogsService: case GogsService:
return "Gogs" return "Gogs"
case OneDevService:
return "OneDev"
case PlainGitService: case PlainGitService:
return "Git" return "Git"
} }
@ -322,5 +325,6 @@ var (
GitlabService, GitlabService,
GiteaService, GiteaService,
GogsService, GogsService,
OneDevService,
} }
) )

View 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.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.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.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_git = Migrating Git Data
migrate.migrating_topics = Migrating Topics migrate.migrating_topics = Migrating Topics
migrate.migrating_milestones = Migrating Milestones migrate.migrating_milestones = Migrating Milestones

View File

@ -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>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -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" .}}

View File

@ -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>

After

Width:  |  Height:  |  Size: 2.5 KiB