Files
gitea/services/migrations/onedev.go
Karl T ee4459488a OneDev migration: fix broken migration caused by various REST API changes in OneDev 7.8.0 and later (#35216)
OneDev migration: fix broken migration caused by various REST API
changes in OneDev 7.8.0 and later

- in REST urls use `~api` instead of `api`
- check minimum required OneDev version before starting migration
- required OneDev version is now 12.0.1
(older versions do not offer necessary API:
https://code.onedev.io/onedev/server/~issues/2491)
- support migrating OneDev subprojects (e.g.
http:/onedev.host/projectA/subProjectB)
- set milestone closed state if milestone is closed in OneDev
- moved memory allocation for milestone JSON decoding into for loop
(which gets 100 milestones per iteration) to fix wrong due dates when
having more than 100 milestones
2025-08-13 23:30:35 -07:00

690 lines
16 KiB
Go

// Copyright 2021 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package migrations
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
base "code.gitea.io/gitea/modules/migration"
"code.gitea.io/gitea/modules/structs"
"github.com/hashicorp/go-version"
)
const OneDevRequiredVersion = "12.0.1"
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
}
repoPath := strings.Trim(u.Path, "/")
u.Path = ""
u.Fragment = ""
log.Trace("Create onedev downloader. BaseURL: %v RepoPath: %s", u, repoPath)
return NewOneDevDownloader(ctx, u, opts.AuthUsername, opts.AuthPassword, repoPath), nil
}
// GitServiceType returns the type of git service
func (f *OneDevDownloaderFactory) GitServiceType() structs.GitServiceType {
return structs.OneDevService
}
type onedevUser struct {
ID int64
Name string
Email string
}
// OneDevDownloader implements a Downloader interface to get repository information
// from OneDev
type OneDevDownloader struct {
base.NullDownloader
client *http.Client
baseURL *url.URL
repoPath string
repoID int64
maxIssueIndex int64
userMap map[int64]*onedevUser
milestoneMap map[int64]string
}
// NewOneDevDownloader creates a new downloader
func NewOneDevDownloader(_ context.Context, baseURL *url.URL, username, password, repoPath string) *OneDevDownloader {
downloader := &OneDevDownloader{
baseURL: baseURL,
repoPath: repoPath,
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
}
// String implements Stringer
func (d *OneDevDownloader) String() string {
return fmt.Sprintf("migration from oneDev server %s [%d]/%s", d.baseURL, d.repoID, d.repoPath)
}
func (d *OneDevDownloader) LogString() string {
if d == nil {
return "<OneDevDownloader nil>"
}
return fmt.Sprintf("<OneDevDownloader %s [%d]/%s>", d.baseURL, d.repoID, d.repoPath)
}
func (d *OneDevDownloader) callAPI(ctx context.Context, endpoint string, parameter map[string]string, result any) 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(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return err
}
resp, err := d.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// special case to read OneDev server version, which is not valid JSON
if presult, ok := result.(**version.Version); ok {
bytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
vers, err := version.NewVersion(string(bytes))
if err != nil {
return err
}
*presult = vers
return nil
}
decoder := json.NewDecoder(resp.Body)
return decoder.Decode(&result)
}
// GetRepoInfo returns repository information
func (d *OneDevDownloader) GetRepoInfo(ctx context.Context) (*base.Repository, error) {
// check OneDev server version
var serverVersion *version.Version
err := d.callAPI(
ctx,
"/~api/version/server",
nil,
&serverVersion,
)
if err != nil {
return nil, fmt.Errorf("failed to get OneDev server version; OneDev %s or newer required", OneDevRequiredVersion)
}
requiredVersion, _ := version.NewVersion(OneDevRequiredVersion)
if serverVersion.LessThan(requiredVersion) {
return nil, fmt.Errorf("OneDev %s or newer required; currently running OneDev %s", OneDevRequiredVersion, serverVersion)
}
info := make([]struct {
ID int64 `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Description string `json:"description"`
}, 0, 1)
err = d.callAPI(
ctx,
"/~api/projects",
map[string]string{
"query": `"Path" is "` + d.repoPath + `"`,
"offset": "0",
"count": "1",
},
&info,
)
if err != nil {
return nil, err
}
if len(info) != 1 {
return nil, fmt.Errorf("Project %s not found", d.repoPath)
}
d.repoID = info[0].ID
cloneURL, err := d.baseURL.Parse(info[0].Path)
if err != nil {
return nil, err
}
return &base.Repository{
Name: info[0].Name,
Description: info[0].Description,
CloneURL: cloneURL.String(),
OriginalURL: cloneURL.String(),
}, nil
}
// GetMilestones returns milestones
func (d *OneDevDownloader) GetMilestones(ctx context.Context) ([]*base.Milestone, error) {
endpoint := fmt.Sprintf("/~api/projects/%d/iterations", d.repoID)
milestones := make([]*base.Milestone, 0, 100)
offset := 0
for {
rawMilestones := make([]struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
DueDay int64 `json:"dueDay"`
Closed bool `json:"closed"`
}, 0, 100)
err := d.callAPI(
ctx,
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
var dueDate *time.Time
if milestone.DueDay != 0 {
d := time.Unix(milestone.DueDay*24*60*60, 0)
dueDate = &d
}
var closedDate *time.Time
state := "open"
if milestone.Closed {
closedDate = dueDate
state = "closed"
}
milestones = append(milestones, &base.Milestone{
Title: milestone.Name,
Description: milestone.Description,
Deadline: dueDate,
Closed: closedDate,
State: state,
})
}
}
return milestones, nil
}
// GetLabels returns labels
func (d *OneDevDownloader) GetLabels(_ context.Context) ([]*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 {
IsPullRequest bool
}
// GetIssues returns issues
func (d *OneDevDownloader) GetIssues(ctx context.Context, page, perPage int) ([]*base.Issue, bool, error) {
type Field struct {
Name string `json:"name"`
Value string `json:"value"`
}
rawIssues := make([]struct {
ID int64 `json:"id"`
Number int64 `json:"number"`
State string `json:"state"`
Title string `json:"title"`
Description string `json:"description"`
SubmitterID int64 `json:"submitterId"`
SubmitDate time.Time `json:"submitDate"`
Fields []Field `json:"fields"`
}, 0, perPage)
err := d.callAPI(
ctx,
"/~api/issues",
map[string]string{
"query": `"Project" is "` + d.repoPath + `"`,
"offset": strconv.Itoa((page - 1) * perPage),
"count": strconv.Itoa(perPage),
"withFields": "true",
},
&rawIssues,
)
if err != nil {
return nil, false, err
}
issues := make([]*base.Issue, 0, len(rawIssues))
for _, issue := range rawIssues {
var label *base.Label
for _, field := range issue.Fields {
if field.Name == "Type" {
label = &base.Label{Name: field.Value}
break
}
}
milestones := make([]struct {
ID int64 `json:"id"`
Name string `json:"name"`
}, 0, 10)
err = d.callAPI(
ctx,
fmt.Sprintf("/~api/issues/%d/iterations", issue.ID),
nil,
&milestones,
)
if err != nil {
return nil, false, err
}
milestoneID := int64(0)
if len(milestones) > 0 {
milestoneID = milestones[0].ID
}
state := strings.ToLower(issue.State)
if state == "released" {
state = "closed"
}
poster := d.tryGetUser(ctx, 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[milestoneID],
State: state,
Created: issue.SubmitDate,
Updated: issue.SubmitDate,
Labels: []*base.Label{label},
ForeignIndex: issue.ID,
Context: onedevIssueContext{IsPullRequest: false},
})
if d.maxIssueIndex < issue.Number {
d.maxIssueIndex = issue.Number
}
}
return issues, len(issues) == 0, nil
}
// GetComments returns comments
func (d *OneDevDownloader) GetComments(ctx context.Context, commentable base.Commentable) ([]*base.Comment, bool, error) {
context, ok := commentable.GetContext().(onedevIssueContext)
if !ok {
return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
}
rawComments := make([]struct {
ID int64 `json:"id"`
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/pulls/%d/comments", commentable.GetForeignIndex())
} else {
endpoint = fmt.Sprintf("/~api/issues/%d/comments", commentable.GetForeignIndex())
}
err := d.callAPI(
ctx,
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]any `json:"data"`
}, 0, 100)
if context.IsPullRequest {
endpoint = fmt.Sprintf("/~api/pulls/%d/changes", commentable.GetForeignIndex())
} else {
endpoint = fmt.Sprintf("/~api/issues/%d/changes", commentable.GetForeignIndex())
}
err = d.callAPI(
ctx,
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(ctx, comment.UserID)
comments = append(comments, &base.Comment{
IssueIndex: commentable.GetLocalIndex(),
Index: comment.ID,
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(ctx, change.UserID)
comments = append(comments, &base.Comment{
IssueIndex: commentable.GetLocalIndex(),
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(ctx context.Context, 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"`
CloseDate *time.Time `json:"closeDate"`
Status string `json:"status"` // Possible values: OPEN, MERGED, DISCARDED
}, 0, perPage)
err := d.callAPI(
ctx,
"/~api/pulls",
map[string]string{
"query": `"Target Project" is "` + d.repoPath + `"`,
"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(
ctx,
fmt.Sprintf("/~api/pulls/%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.Status != "OPEN" {
state = "closed"
closeTime = pr.CloseDate
if pr.Status == "MERGED" { // "DISCARDED"
merged = true
mergedTime = pr.CloseDate
}
}
poster := d.tryGetUser(ctx, 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.repoPath,
},
Base: base.PullRequestBranch{
Ref: pr.TargetBranch,
SHA: mergePreview.TargetHeadCommitHash,
RepoName: d.repoPath,
},
ForeignIndex: pr.ID,
Context: onedevIssueContext{IsPullRequest: true},
})
// SECURITY: Ensure that the PR is safe
_ = CheckAndEnsureSafePR(pullRequests[len(pullRequests)-1], d.baseURL.String(), d)
}
return pullRequests, len(pullRequests) == 0, nil
}
// GetReviews returns pull requests reviews
func (d *OneDevDownloader) GetReviews(ctx context.Context, reviewable base.Reviewable) ([]*base.Review, error) {
rawReviews := make([]struct {
ID int64 `json:"id"`
UserID int64 `json:"userId"`
Status string `json:"status"` // Possible values: PENDING, APPROVED, REQUESTED_FOR_CHANGES, EXCLUDED
}, 0, 100)
err := d.callAPI(
ctx,
fmt.Sprintf("/~api/pulls/%d/reviews", reviewable.GetForeignIndex()),
nil,
&rawReviews,
)
if err != nil {
return nil, err
}
reviews := make([]*base.Review, 0, len(rawReviews))
for _, review := range rawReviews {
state := base.ReviewStatePending
content := ""
switch review.Status {
case "APPROVED":
state = base.ReviewStateApproved
case "REQUESTED_FOR_CHANGES":
state = base.ReviewStateChangesRequested
}
poster := d.tryGetUser(ctx, review.UserID)
reviews = append(reviews, &base.Review{
IssueIndex: reviewable.GetLocalIndex(),
ReviewerID: poster.ID,
ReviewerName: poster.Name,
Content: content,
State: state,
})
}
return reviews, nil
}
// GetTopics return repository topics
func (d *OneDevDownloader) GetTopics(_ context.Context) ([]string, error) {
return []string{}, nil
}
func (d *OneDevDownloader) tryGetUser(ctx context.Context, userID int64) *onedevUser {
user, ok := d.userMap[userID]
if !ok {
// get user name
type RawUser struct {
Name string `json:"name"`
}
var rawUser RawUser
err := d.callAPI(
ctx,
fmt.Sprintf("/~api/users/%d", userID),
nil,
&rawUser,
)
var userName string
if err == nil {
userName = rawUser.Name
} else {
userName = fmt.Sprintf("User %d", userID)
}
// get (primary) user Email address
rawEmailAddresses := make([]struct {
Value string `json:"value"`
Primary bool `json:"primary"`
}, 0, 10)
err = d.callAPI(
ctx,
fmt.Sprintf("/~api/users/%d/email-addresses", userID),
nil,
&rawEmailAddresses,
)
var userEmail string
if err == nil {
for _, email := range rawEmailAddresses {
if userEmail == "" || email.Primary {
userEmail = email.Value
}
if email.Primary {
break
}
}
}
user = &onedevUser{
ID: userID,
Name: userName,
Email: userEmail,
}
d.userMap[userID] = user
}
return user
}