code.gitea.io/gitea@v1.21.7/services/migrations/onedev.go (about)

     1  // Copyright 2021 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package migrations
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"net/http"
    10  	"net/url"
    11  	"strconv"
    12  	"strings"
    13  	"time"
    14  
    15  	"code.gitea.io/gitea/modules/json"
    16  	"code.gitea.io/gitea/modules/log"
    17  	base "code.gitea.io/gitea/modules/migration"
    18  	"code.gitea.io/gitea/modules/structs"
    19  )
    20  
    21  var (
    22  	_ base.Downloader        = &OneDevDownloader{}
    23  	_ base.DownloaderFactory = &OneDevDownloaderFactory{}
    24  )
    25  
    26  func init() {
    27  	RegisterDownloaderFactory(&OneDevDownloaderFactory{})
    28  }
    29  
    30  // OneDevDownloaderFactory defines a downloader factory
    31  type OneDevDownloaderFactory struct{}
    32  
    33  // New returns a downloader related to this factory according MigrateOptions
    34  func (f *OneDevDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
    35  	u, err := url.Parse(opts.CloneAddr)
    36  	if err != nil {
    37  		return nil, err
    38  	}
    39  
    40  	var repoName string
    41  
    42  	fields := strings.Split(strings.Trim(u.Path, "/"), "/")
    43  	if len(fields) == 2 && fields[0] == "projects" {
    44  		repoName = fields[1]
    45  	} else if len(fields) == 1 {
    46  		repoName = fields[0]
    47  	} else {
    48  		return nil, fmt.Errorf("invalid path: %s", u.Path)
    49  	}
    50  
    51  	u.Path = ""
    52  	u.Fragment = ""
    53  
    54  	log.Trace("Create onedev downloader. BaseURL: %v RepoName: %s", u, repoName)
    55  
    56  	return NewOneDevDownloader(ctx, u, opts.AuthUsername, opts.AuthPassword, repoName), nil
    57  }
    58  
    59  // GitServiceType returns the type of git service
    60  func (f *OneDevDownloaderFactory) GitServiceType() structs.GitServiceType {
    61  	return structs.OneDevService
    62  }
    63  
    64  type onedevUser struct {
    65  	ID    int64  `json:"id"`
    66  	Name  string `json:"name"`
    67  	Email string `json:"email"`
    68  }
    69  
    70  // OneDevDownloader implements a Downloader interface to get repository information
    71  // from OneDev
    72  type OneDevDownloader struct {
    73  	base.NullDownloader
    74  	ctx           context.Context
    75  	client        *http.Client
    76  	baseURL       *url.URL
    77  	repoName      string
    78  	repoID        int64
    79  	maxIssueIndex int64
    80  	userMap       map[int64]*onedevUser
    81  	milestoneMap  map[int64]string
    82  }
    83  
    84  // SetContext set context
    85  func (d *OneDevDownloader) SetContext(ctx context.Context) {
    86  	d.ctx = ctx
    87  }
    88  
    89  // NewOneDevDownloader creates a new downloader
    90  func NewOneDevDownloader(ctx context.Context, baseURL *url.URL, username, password, repoName string) *OneDevDownloader {
    91  	downloader := &OneDevDownloader{
    92  		ctx:      ctx,
    93  		baseURL:  baseURL,
    94  		repoName: repoName,
    95  		client: &http.Client{
    96  			Transport: &http.Transport{
    97  				Proxy: func(req *http.Request) (*url.URL, error) {
    98  					if len(username) > 0 && len(password) > 0 {
    99  						req.SetBasicAuth(username, password)
   100  					}
   101  					return nil, nil
   102  				},
   103  			},
   104  		},
   105  		userMap:      make(map[int64]*onedevUser),
   106  		milestoneMap: make(map[int64]string),
   107  	}
   108  
   109  	return downloader
   110  }
   111  
   112  // String implements Stringer
   113  func (d *OneDevDownloader) String() string {
   114  	return fmt.Sprintf("migration from oneDev server %s [%d]/%s", d.baseURL, d.repoID, d.repoName)
   115  }
   116  
   117  func (d *OneDevDownloader) LogString() string {
   118  	if d == nil {
   119  		return "<OneDevDownloader nil>"
   120  	}
   121  	return fmt.Sprintf("<OneDevDownloader %s [%d]/%s>", d.baseURL, d.repoID, d.repoName)
   122  }
   123  
   124  func (d *OneDevDownloader) callAPI(endpoint string, parameter map[string]string, result any) error {
   125  	u, err := d.baseURL.Parse(endpoint)
   126  	if err != nil {
   127  		return err
   128  	}
   129  
   130  	if parameter != nil {
   131  		query := u.Query()
   132  		for k, v := range parameter {
   133  			query.Set(k, v)
   134  		}
   135  		u.RawQuery = query.Encode()
   136  	}
   137  
   138  	req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil)
   139  	if err != nil {
   140  		return err
   141  	}
   142  
   143  	resp, err := d.client.Do(req)
   144  	if err != nil {
   145  		return err
   146  	}
   147  	defer resp.Body.Close()
   148  
   149  	decoder := json.NewDecoder(resp.Body)
   150  	return decoder.Decode(&result)
   151  }
   152  
   153  // GetRepoInfo returns repository information
   154  func (d *OneDevDownloader) GetRepoInfo() (*base.Repository, error) {
   155  	info := make([]struct {
   156  		ID          int64  `json:"id"`
   157  		Name        string `json:"name"`
   158  		Description string `json:"description"`
   159  	}, 0, 1)
   160  
   161  	err := d.callAPI(
   162  		"/api/projects",
   163  		map[string]string{
   164  			"query":  `"Name" is "` + d.repoName + `"`,
   165  			"offset": "0",
   166  			"count":  "1",
   167  		},
   168  		&info,
   169  	)
   170  	if err != nil {
   171  		return nil, err
   172  	}
   173  	if len(info) != 1 {
   174  		return nil, fmt.Errorf("Project %s not found", d.repoName)
   175  	}
   176  
   177  	d.repoID = info[0].ID
   178  
   179  	cloneURL, err := d.baseURL.Parse(info[0].Name)
   180  	if err != nil {
   181  		return nil, err
   182  	}
   183  	originalURL, err := d.baseURL.Parse("/projects/" + info[0].Name)
   184  	if err != nil {
   185  		return nil, err
   186  	}
   187  
   188  	return &base.Repository{
   189  		Name:        info[0].Name,
   190  		Description: info[0].Description,
   191  		CloneURL:    cloneURL.String(),
   192  		OriginalURL: originalURL.String(),
   193  	}, nil
   194  }
   195  
   196  // GetMilestones returns milestones
   197  func (d *OneDevDownloader) GetMilestones() ([]*base.Milestone, error) {
   198  	rawMilestones := make([]struct {
   199  		ID          int64      `json:"id"`
   200  		Name        string     `json:"name"`
   201  		Description string     `json:"description"`
   202  		DueDate     *time.Time `json:"dueDate"`
   203  		Closed      bool       `json:"closed"`
   204  	}, 0, 100)
   205  
   206  	endpoint := fmt.Sprintf("/api/projects/%d/milestones", d.repoID)
   207  
   208  	milestones := make([]*base.Milestone, 0, 100)
   209  	offset := 0
   210  	for {
   211  		err := d.callAPI(
   212  			endpoint,
   213  			map[string]string{
   214  				"offset": strconv.Itoa(offset),
   215  				"count":  "100",
   216  			},
   217  			&rawMilestones,
   218  		)
   219  		if err != nil {
   220  			return nil, err
   221  		}
   222  		if len(rawMilestones) == 0 {
   223  			break
   224  		}
   225  		offset += 100
   226  
   227  		for _, milestone := range rawMilestones {
   228  			d.milestoneMap[milestone.ID] = milestone.Name
   229  			closed := milestone.DueDate
   230  			if !milestone.Closed {
   231  				closed = nil
   232  			}
   233  
   234  			milestones = append(milestones, &base.Milestone{
   235  				Title:       milestone.Name,
   236  				Description: milestone.Description,
   237  				Deadline:    milestone.DueDate,
   238  				Closed:      closed,
   239  			})
   240  		}
   241  	}
   242  	return milestones, nil
   243  }
   244  
   245  // GetLabels returns labels
   246  func (d *OneDevDownloader) GetLabels() ([]*base.Label, error) {
   247  	return []*base.Label{
   248  		{
   249  			Name:  "Bug",
   250  			Color: "f64e60",
   251  		},
   252  		{
   253  			Name:  "Build Failure",
   254  			Color: "f64e60",
   255  		},
   256  		{
   257  			Name:  "Discussion",
   258  			Color: "8950fc",
   259  		},
   260  		{
   261  			Name:  "Improvement",
   262  			Color: "1bc5bd",
   263  		},
   264  		{
   265  			Name:  "New Feature",
   266  			Color: "1bc5bd",
   267  		},
   268  		{
   269  			Name:  "Support Request",
   270  			Color: "8950fc",
   271  		},
   272  	}, nil
   273  }
   274  
   275  type onedevIssueContext struct {
   276  	IsPullRequest bool
   277  }
   278  
   279  // GetIssues returns issues
   280  func (d *OneDevDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
   281  	rawIssues := make([]struct {
   282  		ID          int64     `json:"id"`
   283  		Number      int64     `json:"number"`
   284  		State       string    `json:"state"`
   285  		Title       string    `json:"title"`
   286  		Description string    `json:"description"`
   287  		SubmitterID int64     `json:"submitterId"`
   288  		SubmitDate  time.Time `json:"submitDate"`
   289  	}, 0, perPage)
   290  
   291  	err := d.callAPI(
   292  		"/api/issues",
   293  		map[string]string{
   294  			"query":  `"Project" is "` + d.repoName + `"`,
   295  			"offset": strconv.Itoa((page - 1) * perPage),
   296  			"count":  strconv.Itoa(perPage),
   297  		},
   298  		&rawIssues,
   299  	)
   300  	if err != nil {
   301  		return nil, false, err
   302  	}
   303  
   304  	issues := make([]*base.Issue, 0, len(rawIssues))
   305  	for _, issue := range rawIssues {
   306  		fields := make([]struct {
   307  			Name  string `json:"name"`
   308  			Value string `json:"value"`
   309  		}, 0, 10)
   310  		err := d.callAPI(
   311  			fmt.Sprintf("/api/issues/%d/fields", issue.ID),
   312  			nil,
   313  			&fields,
   314  		)
   315  		if err != nil {
   316  			return nil, false, err
   317  		}
   318  
   319  		var label *base.Label
   320  		for _, field := range fields {
   321  			if field.Name == "Type" {
   322  				label = &base.Label{Name: field.Value}
   323  				break
   324  			}
   325  		}
   326  
   327  		milestones := make([]struct {
   328  			ID   int64  `json:"id"`
   329  			Name string `json:"name"`
   330  		}, 0, 10)
   331  		err = d.callAPI(
   332  			fmt.Sprintf("/api/issues/%d/milestones", issue.ID),
   333  			nil,
   334  			&milestones,
   335  		)
   336  		if err != nil {
   337  			return nil, false, err
   338  		}
   339  		milestoneID := int64(0)
   340  		if len(milestones) > 0 {
   341  			milestoneID = milestones[0].ID
   342  		}
   343  
   344  		state := strings.ToLower(issue.State)
   345  		if state == "released" {
   346  			state = "closed"
   347  		}
   348  		poster := d.tryGetUser(issue.SubmitterID)
   349  		issues = append(issues, &base.Issue{
   350  			Title:        issue.Title,
   351  			Number:       issue.Number,
   352  			PosterName:   poster.Name,
   353  			PosterEmail:  poster.Email,
   354  			Content:      issue.Description,
   355  			Milestone:    d.milestoneMap[milestoneID],
   356  			State:        state,
   357  			Created:      issue.SubmitDate,
   358  			Updated:      issue.SubmitDate,
   359  			Labels:       []*base.Label{label},
   360  			ForeignIndex: issue.ID,
   361  			Context:      onedevIssueContext{IsPullRequest: false},
   362  		})
   363  
   364  		if d.maxIssueIndex < issue.Number {
   365  			d.maxIssueIndex = issue.Number
   366  		}
   367  	}
   368  
   369  	return issues, len(issues) == 0, nil
   370  }
   371  
   372  // GetComments returns comments
   373  func (d *OneDevDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
   374  	context, ok := commentable.GetContext().(onedevIssueContext)
   375  	if !ok {
   376  		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
   377  	}
   378  
   379  	rawComments := make([]struct {
   380  		ID      int64     `json:"id"`
   381  		Date    time.Time `json:"date"`
   382  		UserID  int64     `json:"userId"`
   383  		Content string    `json:"content"`
   384  	}, 0, 100)
   385  
   386  	var endpoint string
   387  	if context.IsPullRequest {
   388  		endpoint = fmt.Sprintf("/api/pull-requests/%d/comments", commentable.GetForeignIndex())
   389  	} else {
   390  		endpoint = fmt.Sprintf("/api/issues/%d/comments", commentable.GetForeignIndex())
   391  	}
   392  
   393  	err := d.callAPI(
   394  		endpoint,
   395  		nil,
   396  		&rawComments,
   397  	)
   398  	if err != nil {
   399  		return nil, false, err
   400  	}
   401  
   402  	rawChanges := make([]struct {
   403  		Date   time.Time      `json:"date"`
   404  		UserID int64          `json:"userId"`
   405  		Data   map[string]any `json:"data"`
   406  	}, 0, 100)
   407  
   408  	if context.IsPullRequest {
   409  		endpoint = fmt.Sprintf("/api/pull-requests/%d/changes", commentable.GetForeignIndex())
   410  	} else {
   411  		endpoint = fmt.Sprintf("/api/issues/%d/changes", commentable.GetForeignIndex())
   412  	}
   413  
   414  	err = d.callAPI(
   415  		endpoint,
   416  		nil,
   417  		&rawChanges,
   418  	)
   419  	if err != nil {
   420  		return nil, false, err
   421  	}
   422  
   423  	comments := make([]*base.Comment, 0, len(rawComments)+len(rawChanges))
   424  	for _, comment := range rawComments {
   425  		if len(comment.Content) == 0 {
   426  			continue
   427  		}
   428  		poster := d.tryGetUser(comment.UserID)
   429  		comments = append(comments, &base.Comment{
   430  			IssueIndex:  commentable.GetLocalIndex(),
   431  			Index:       comment.ID,
   432  			PosterID:    poster.ID,
   433  			PosterName:  poster.Name,
   434  			PosterEmail: poster.Email,
   435  			Content:     comment.Content,
   436  			Created:     comment.Date,
   437  			Updated:     comment.Date,
   438  		})
   439  	}
   440  	for _, change := range rawChanges {
   441  		contentV, ok := change.Data["content"]
   442  		if !ok {
   443  			contentV, ok = change.Data["comment"]
   444  			if !ok {
   445  				continue
   446  			}
   447  		}
   448  		content, ok := contentV.(string)
   449  		if !ok || len(content) == 0 {
   450  			continue
   451  		}
   452  
   453  		poster := d.tryGetUser(change.UserID)
   454  		comments = append(comments, &base.Comment{
   455  			IssueIndex:  commentable.GetLocalIndex(),
   456  			PosterID:    poster.ID,
   457  			PosterName:  poster.Name,
   458  			PosterEmail: poster.Email,
   459  			Content:     content,
   460  			Created:     change.Date,
   461  			Updated:     change.Date,
   462  		})
   463  	}
   464  
   465  	return comments, true, nil
   466  }
   467  
   468  // GetPullRequests returns pull requests
   469  func (d *OneDevDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
   470  	rawPullRequests := make([]struct {
   471  		ID             int64     `json:"id"`
   472  		Number         int64     `json:"number"`
   473  		Title          string    `json:"title"`
   474  		SubmitterID    int64     `json:"submitterId"`
   475  		SubmitDate     time.Time `json:"submitDate"`
   476  		Description    string    `json:"description"`
   477  		TargetBranch   string    `json:"targetBranch"`
   478  		SourceBranch   string    `json:"sourceBranch"`
   479  		BaseCommitHash string    `json:"baseCommitHash"`
   480  		CloseInfo      *struct {
   481  			Date   *time.Time `json:"date"`
   482  			Status string     `json:"status"`
   483  		}
   484  	}, 0, perPage)
   485  
   486  	err := d.callAPI(
   487  		"/api/pull-requests",
   488  		map[string]string{
   489  			"query":  `"Target Project" is "` + d.repoName + `"`,
   490  			"offset": strconv.Itoa((page - 1) * perPage),
   491  			"count":  strconv.Itoa(perPage),
   492  		},
   493  		&rawPullRequests,
   494  	)
   495  	if err != nil {
   496  		return nil, false, err
   497  	}
   498  
   499  	pullRequests := make([]*base.PullRequest, 0, len(rawPullRequests))
   500  	for _, pr := range rawPullRequests {
   501  		var mergePreview struct {
   502  			TargetHeadCommitHash string `json:"targetHeadCommitHash"`
   503  			HeadCommitHash       string `json:"headCommitHash"`
   504  			MergeStrategy        string `json:"mergeStrategy"`
   505  			MergeCommitHash      string `json:"mergeCommitHash"`
   506  		}
   507  		err := d.callAPI(
   508  			fmt.Sprintf("/api/pull-requests/%d/merge-preview", pr.ID),
   509  			nil,
   510  			&mergePreview,
   511  		)
   512  		if err != nil {
   513  			return nil, false, err
   514  		}
   515  
   516  		state := "open"
   517  		merged := false
   518  		var closeTime *time.Time
   519  		var mergedTime *time.Time
   520  		if pr.CloseInfo != nil {
   521  			state = "closed"
   522  			closeTime = pr.CloseInfo.Date
   523  			if pr.CloseInfo.Status == "MERGED" { // "DISCARDED"
   524  				merged = true
   525  				mergedTime = pr.CloseInfo.Date
   526  			}
   527  		}
   528  		poster := d.tryGetUser(pr.SubmitterID)
   529  
   530  		number := pr.Number + d.maxIssueIndex
   531  		pullRequests = append(pullRequests, &base.PullRequest{
   532  			Title:      pr.Title,
   533  			Number:     number,
   534  			PosterName: poster.Name,
   535  			PosterID:   poster.ID,
   536  			Content:    pr.Description,
   537  			State:      state,
   538  			Created:    pr.SubmitDate,
   539  			Updated:    pr.SubmitDate,
   540  			Closed:     closeTime,
   541  			Merged:     merged,
   542  			MergedTime: mergedTime,
   543  			Head: base.PullRequestBranch{
   544  				Ref:      pr.SourceBranch,
   545  				SHA:      mergePreview.HeadCommitHash,
   546  				RepoName: d.repoName,
   547  			},
   548  			Base: base.PullRequestBranch{
   549  				Ref:      pr.TargetBranch,
   550  				SHA:      mergePreview.TargetHeadCommitHash,
   551  				RepoName: d.repoName,
   552  			},
   553  			ForeignIndex: pr.ID,
   554  			Context:      onedevIssueContext{IsPullRequest: true},
   555  		})
   556  
   557  		// SECURITY: Ensure that the PR is safe
   558  		_ = CheckAndEnsureSafePR(pullRequests[len(pullRequests)-1], d.baseURL.String(), d)
   559  	}
   560  
   561  	return pullRequests, len(pullRequests) == 0, nil
   562  }
   563  
   564  // GetReviews returns pull requests reviews
   565  func (d *OneDevDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
   566  	rawReviews := make([]struct {
   567  		ID     int64 `json:"id"`
   568  		UserID int64 `json:"userId"`
   569  		Result *struct {
   570  			Commit   string `json:"commit"`
   571  			Approved bool   `json:"approved"`
   572  			Comment  string `json:"comment"`
   573  		}
   574  	}, 0, 100)
   575  
   576  	err := d.callAPI(
   577  		fmt.Sprintf("/api/pull-requests/%d/reviews", reviewable.GetForeignIndex()),
   578  		nil,
   579  		&rawReviews,
   580  	)
   581  	if err != nil {
   582  		return nil, err
   583  	}
   584  
   585  	reviews := make([]*base.Review, 0, len(rawReviews))
   586  	for _, review := range rawReviews {
   587  		state := base.ReviewStatePending
   588  		content := ""
   589  		if review.Result != nil {
   590  			if len(review.Result.Comment) > 0 {
   591  				state = base.ReviewStateCommented
   592  				content = review.Result.Comment
   593  			}
   594  			if review.Result.Approved {
   595  				state = base.ReviewStateApproved
   596  			}
   597  		}
   598  
   599  		poster := d.tryGetUser(review.UserID)
   600  		reviews = append(reviews, &base.Review{
   601  			IssueIndex:   reviewable.GetLocalIndex(),
   602  			ReviewerID:   poster.ID,
   603  			ReviewerName: poster.Name,
   604  			Content:      content,
   605  			State:        state,
   606  		})
   607  	}
   608  
   609  	return reviews, nil
   610  }
   611  
   612  // GetTopics return repository topics
   613  func (d *OneDevDownloader) GetTopics() ([]string, error) {
   614  	return []string{}, nil
   615  }
   616  
   617  func (d *OneDevDownloader) tryGetUser(userID int64) *onedevUser {
   618  	user, ok := d.userMap[userID]
   619  	if !ok {
   620  		err := d.callAPI(
   621  			fmt.Sprintf("/api/users/%d", userID),
   622  			nil,
   623  			&user,
   624  		)
   625  		if err != nil {
   626  			user = &onedevUser{
   627  				Name: fmt.Sprintf("User %d", userID),
   628  			}
   629  		}
   630  		d.userMap[userID] = user
   631  	}
   632  
   633  	return user
   634  }