* 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] Labelstags/v1.16.0-rc1
@@ -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 | |||
} |
@@ -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) | |||
} | |||
@@ -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:"-"` | |||
} |
@@ -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"} | |||
} | |||
@@ -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 |
@@ -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 | |||
}) | |||
@@ -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, |
@@ -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{ | |||
{ |
@@ -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 { |
@@ -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) | |||
} |
@@ -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{ | |||
{ |
@@ -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, |
@@ -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{ | |||
{ |
@@ -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), | |||
} | |||
} | |||
@@ -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{ |
@@ -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...) | |||
@@ -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 | |||
} |
@@ -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) | |||
} |
@@ -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) { |
@@ -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, | |||
} | |||
) |
@@ -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 |
@@ -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> |
@@ -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" .}} |
@@ -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> |