code.gitea.io/gitea@v1.22.3/services/migrations/gitlab.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package migrations
     5  
     6  import (
     7  	"context"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"net/url"
    13  	"path"
    14  	"regexp"
    15  	"strings"
    16  	"time"
    17  
    18  	issues_model "code.gitea.io/gitea/models/issues"
    19  	"code.gitea.io/gitea/modules/container"
    20  	"code.gitea.io/gitea/modules/log"
    21  	base "code.gitea.io/gitea/modules/migration"
    22  	"code.gitea.io/gitea/modules/structs"
    23  
    24  	"github.com/xanzy/go-gitlab"
    25  )
    26  
    27  var (
    28  	_ base.Downloader        = &GitlabDownloader{}
    29  	_ base.DownloaderFactory = &GitlabDownloaderFactory{}
    30  )
    31  
    32  func init() {
    33  	RegisterDownloaderFactory(&GitlabDownloaderFactory{})
    34  }
    35  
    36  // GitlabDownloaderFactory defines a gitlab downloader factory
    37  type GitlabDownloaderFactory struct{}
    38  
    39  // New returns a Downloader related to this factory according MigrateOptions
    40  func (f *GitlabDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) {
    41  	u, err := url.Parse(opts.CloneAddr)
    42  	if err != nil {
    43  		return nil, err
    44  	}
    45  
    46  	baseURL := u.Scheme + "://" + u.Host
    47  	repoNameSpace := strings.TrimPrefix(u.Path, "/")
    48  	repoNameSpace = strings.TrimSuffix(repoNameSpace, ".git")
    49  
    50  	log.Trace("Create gitlab downloader. BaseURL: %s RepoName: %s", baseURL, repoNameSpace)
    51  
    52  	return NewGitlabDownloader(ctx, baseURL, repoNameSpace, opts.AuthUsername, opts.AuthPassword, opts.AuthToken)
    53  }
    54  
    55  // GitServiceType returns the type of git service
    56  func (f *GitlabDownloaderFactory) GitServiceType() structs.GitServiceType {
    57  	return structs.GitlabService
    58  }
    59  
    60  type gitlabIIDResolver struct {
    61  	maxIssueIID int64
    62  	frozen      bool
    63  }
    64  
    65  func (r *gitlabIIDResolver) recordIssueIID(issueIID int) {
    66  	if r.frozen {
    67  		panic("cannot record issue IID after pull request IID generation has started")
    68  	}
    69  	r.maxIssueIID = max(r.maxIssueIID, int64(issueIID))
    70  }
    71  
    72  func (r *gitlabIIDResolver) generatePullRequestNumber(mrIID int) int64 {
    73  	r.frozen = true
    74  	return r.maxIssueIID + int64(mrIID)
    75  }
    76  
    77  // GitlabDownloader implements a Downloader interface to get repository information
    78  // from gitlab via go-gitlab
    79  // - issueCount is incremented in GetIssues() to ensure PR and Issue numbers do not overlap,
    80  // because Gitlab has individual Issue and Pull Request numbers.
    81  type GitlabDownloader struct {
    82  	base.NullDownloader
    83  	ctx         context.Context
    84  	client      *gitlab.Client
    85  	baseURL     string
    86  	repoID      int
    87  	repoName    string
    88  	iidResolver gitlabIIDResolver
    89  	maxPerPage  int
    90  }
    91  
    92  // NewGitlabDownloader creates a gitlab Downloader via gitlab API
    93  //
    94  //	Use either a username/password, personal token entered into the username field, or anonymous/public access
    95  //	Note: Public access only allows very basic access
    96  func NewGitlabDownloader(ctx context.Context, baseURL, repoPath, username, password, token string) (*GitlabDownloader, error) {
    97  	gitlabClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
    98  	// Only use basic auth if token is blank and password is NOT
    99  	// Basic auth will fail with empty strings, but empty token will allow anonymous public API usage
   100  	if token == "" && password != "" {
   101  		gitlabClient, err = gitlab.NewBasicAuthClient(username, password, gitlab.WithBaseURL(baseURL), gitlab.WithHTTPClient(NewMigrationHTTPClient()))
   102  	}
   103  
   104  	if err != nil {
   105  		log.Trace("Error logging into gitlab: %v", err)
   106  		return nil, err
   107  	}
   108  
   109  	// split namespace and subdirectory
   110  	pathParts := strings.Split(strings.Trim(repoPath, "/"), "/")
   111  	var resp *gitlab.Response
   112  	u, _ := url.Parse(baseURL)
   113  	for len(pathParts) >= 2 {
   114  		_, resp, err = gitlabClient.Version.GetVersion()
   115  		if err == nil || resp != nil && resp.StatusCode == http.StatusUnauthorized {
   116  			err = nil // if no authentication given, this still should work
   117  			break
   118  		}
   119  
   120  		u.Path = path.Join(u.Path, pathParts[0])
   121  		baseURL = u.String()
   122  		pathParts = pathParts[1:]
   123  		_ = gitlab.WithBaseURL(baseURL)(gitlabClient)
   124  		repoPath = strings.Join(pathParts, "/")
   125  	}
   126  	if err != nil {
   127  		log.Trace("Error could not get gitlab version: %v", err)
   128  		return nil, err
   129  	}
   130  
   131  	log.Trace("gitlab downloader: use BaseURL: '%s' and RepoPath: '%s'", baseURL, repoPath)
   132  
   133  	// Grab and store project/repo ID here, due to issues using the URL escaped path
   134  	gr, _, err := gitlabClient.Projects.GetProject(repoPath, nil, nil, gitlab.WithContext(ctx))
   135  	if err != nil {
   136  		log.Trace("Error retrieving project: %v", err)
   137  		return nil, err
   138  	}
   139  
   140  	if gr == nil {
   141  		log.Trace("Error getting project, project is nil")
   142  		return nil, errors.New("Error getting project, project is nil")
   143  	}
   144  
   145  	return &GitlabDownloader{
   146  		ctx:        ctx,
   147  		client:     gitlabClient,
   148  		baseURL:    baseURL,
   149  		repoID:     gr.ID,
   150  		repoName:   gr.Name,
   151  		maxPerPage: 100,
   152  	}, nil
   153  }
   154  
   155  // String implements Stringer
   156  func (g *GitlabDownloader) String() string {
   157  	return fmt.Sprintf("migration from gitlab server %s [%d]/%s", g.baseURL, g.repoID, g.repoName)
   158  }
   159  
   160  func (g *GitlabDownloader) LogString() string {
   161  	if g == nil {
   162  		return "<GitlabDownloader nil>"
   163  	}
   164  	return fmt.Sprintf("<GitlabDownloader %s [%d]/%s>", g.baseURL, g.repoID, g.repoName)
   165  }
   166  
   167  // SetContext set context
   168  func (g *GitlabDownloader) SetContext(ctx context.Context) {
   169  	g.ctx = ctx
   170  }
   171  
   172  // GetRepoInfo returns a repository information
   173  func (g *GitlabDownloader) GetRepoInfo() (*base.Repository, error) {
   174  	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  
   179  	var private bool
   180  	switch gr.Visibility {
   181  	case gitlab.InternalVisibility:
   182  		private = true
   183  	case gitlab.PrivateVisibility:
   184  		private = true
   185  	}
   186  
   187  	var owner string
   188  	if gr.Owner == nil {
   189  		log.Trace("gr.Owner is nil, trying to get owner from Namespace")
   190  		if gr.Namespace != nil && gr.Namespace.Kind == "user" {
   191  			owner = gr.Namespace.Path
   192  		}
   193  	} else {
   194  		owner = gr.Owner.Username
   195  	}
   196  
   197  	// convert gitlab repo to stand Repo
   198  	return &base.Repository{
   199  		Owner:         owner,
   200  		Name:          gr.Name,
   201  		IsPrivate:     private,
   202  		Description:   gr.Description,
   203  		OriginalURL:   gr.WebURL,
   204  		CloneURL:      gr.HTTPURLToRepo,
   205  		DefaultBranch: gr.DefaultBranch,
   206  	}, nil
   207  }
   208  
   209  // GetTopics return gitlab topics
   210  func (g *GitlabDownloader) GetTopics() ([]string, error) {
   211  	gr, _, err := g.client.Projects.GetProject(g.repoID, nil, nil, gitlab.WithContext(g.ctx))
   212  	if err != nil {
   213  		return nil, err
   214  	}
   215  	return gr.TagList, err
   216  }
   217  
   218  // GetMilestones returns milestones
   219  func (g *GitlabDownloader) GetMilestones() ([]*base.Milestone, error) {
   220  	perPage := g.maxPerPage
   221  	state := "all"
   222  	milestones := make([]*base.Milestone, 0, perPage)
   223  	for i := 1; ; i++ {
   224  		ms, _, err := g.client.Milestones.ListMilestones(g.repoID, &gitlab.ListMilestonesOptions{
   225  			State: &state,
   226  			ListOptions: gitlab.ListOptions{
   227  				Page:    i,
   228  				PerPage: perPage,
   229  			},
   230  		}, nil, gitlab.WithContext(g.ctx))
   231  		if err != nil {
   232  			return nil, err
   233  		}
   234  
   235  		for _, m := range ms {
   236  			var desc string
   237  			if m.Description != "" {
   238  				desc = m.Description
   239  			}
   240  			state := "open"
   241  			var closedAt *time.Time
   242  			if m.State != "" {
   243  				state = m.State
   244  				if state == "closed" {
   245  					closedAt = m.UpdatedAt
   246  				}
   247  			}
   248  
   249  			var deadline *time.Time
   250  			if m.DueDate != nil {
   251  				deadlineParsed, err := time.Parse("2006-01-02", m.DueDate.String())
   252  				if err != nil {
   253  					log.Trace("Error parsing Milestone DueDate time")
   254  					deadline = nil
   255  				} else {
   256  					deadline = &deadlineParsed
   257  				}
   258  			}
   259  
   260  			milestones = append(milestones, &base.Milestone{
   261  				Title:       m.Title,
   262  				Description: desc,
   263  				Deadline:    deadline,
   264  				State:       state,
   265  				Created:     *m.CreatedAt,
   266  				Updated:     m.UpdatedAt,
   267  				Closed:      closedAt,
   268  			})
   269  		}
   270  		if len(ms) < perPage {
   271  			break
   272  		}
   273  	}
   274  	return milestones, nil
   275  }
   276  
   277  func (g *GitlabDownloader) normalizeColor(val string) string {
   278  	val = strings.TrimLeft(val, "#")
   279  	val = strings.ToLower(val)
   280  	if len(val) == 3 {
   281  		c := []rune(val)
   282  		val = fmt.Sprintf("%c%c%c%c%c%c", c[0], c[0], c[1], c[1], c[2], c[2])
   283  	}
   284  	if len(val) != 6 {
   285  		return ""
   286  	}
   287  	return val
   288  }
   289  
   290  // GetLabels returns labels
   291  func (g *GitlabDownloader) GetLabels() ([]*base.Label, error) {
   292  	perPage := g.maxPerPage
   293  	labels := make([]*base.Label, 0, perPage)
   294  	for i := 1; ; i++ {
   295  		ls, _, err := g.client.Labels.ListLabels(g.repoID, &gitlab.ListLabelsOptions{ListOptions: gitlab.ListOptions{
   296  			Page:    i,
   297  			PerPage: perPage,
   298  		}}, nil, gitlab.WithContext(g.ctx))
   299  		if err != nil {
   300  			return nil, err
   301  		}
   302  		for _, label := range ls {
   303  			baseLabel := &base.Label{
   304  				Name:        label.Name,
   305  				Color:       g.normalizeColor(label.Color),
   306  				Description: label.Description,
   307  			}
   308  			labels = append(labels, baseLabel)
   309  		}
   310  		if len(ls) < perPage {
   311  			break
   312  		}
   313  	}
   314  	return labels, nil
   315  }
   316  
   317  func (g *GitlabDownloader) convertGitlabRelease(rel *gitlab.Release) *base.Release {
   318  	var zero int
   319  	r := &base.Release{
   320  		TagName:         rel.TagName,
   321  		TargetCommitish: rel.Commit.ID,
   322  		Name:            rel.Name,
   323  		Body:            rel.Description,
   324  		Created:         *rel.CreatedAt,
   325  		PublisherID:     int64(rel.Author.ID),
   326  		PublisherName:   rel.Author.Username,
   327  	}
   328  
   329  	httpClient := NewMigrationHTTPClient()
   330  
   331  	for k, asset := range rel.Assets.Links {
   332  		assetID := asset.ID // Don't optimize this, for closure we need a local variable
   333  		r.Assets = append(r.Assets, &base.ReleaseAsset{
   334  			ID:            int64(asset.ID),
   335  			Name:          asset.Name,
   336  			ContentType:   &rel.Assets.Sources[k].Format,
   337  			Size:          &zero,
   338  			DownloadCount: &zero,
   339  			DownloadFunc: func() (io.ReadCloser, error) {
   340  				link, _, err := g.client.ReleaseLinks.GetReleaseLink(g.repoID, rel.TagName, assetID, gitlab.WithContext(g.ctx))
   341  				if err != nil {
   342  					return nil, err
   343  				}
   344  
   345  				if !hasBaseURL(link.URL, g.baseURL) {
   346  					WarnAndNotice("Unexpected AssetURL for assetID[%d] in %s: %s", assetID, g, link.URL)
   347  					return io.NopCloser(strings.NewReader(link.URL)), nil
   348  				}
   349  
   350  				req, err := http.NewRequest("GET", link.URL, nil)
   351  				if err != nil {
   352  					return nil, err
   353  				}
   354  				req = req.WithContext(g.ctx)
   355  				resp, err := httpClient.Do(req)
   356  				if err != nil {
   357  					return nil, err
   358  				}
   359  
   360  				// resp.Body is closed by the uploader
   361  				return resp.Body, nil
   362  			},
   363  		})
   364  	}
   365  	return r
   366  }
   367  
   368  // GetReleases returns releases
   369  func (g *GitlabDownloader) GetReleases() ([]*base.Release, error) {
   370  	perPage := g.maxPerPage
   371  	releases := make([]*base.Release, 0, perPage)
   372  	for i := 1; ; i++ {
   373  		ls, _, err := g.client.Releases.ListReleases(g.repoID, &gitlab.ListReleasesOptions{
   374  			ListOptions: gitlab.ListOptions{
   375  				Page:    i,
   376  				PerPage: perPage,
   377  			},
   378  		}, nil, gitlab.WithContext(g.ctx))
   379  		if err != nil {
   380  			return nil, err
   381  		}
   382  
   383  		for _, release := range ls {
   384  			releases = append(releases, g.convertGitlabRelease(release))
   385  		}
   386  		if len(ls) < perPage {
   387  			break
   388  		}
   389  	}
   390  	return releases, nil
   391  }
   392  
   393  type gitlabIssueContext struct {
   394  	IsMergeRequest bool
   395  }
   396  
   397  // GetIssues returns issues according start and limit
   398  //
   399  //	Note: issue label description and colors are not supported by the go-gitlab library at this time
   400  func (g *GitlabDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) {
   401  	state := "all"
   402  	sort := "asc"
   403  
   404  	if perPage > g.maxPerPage {
   405  		perPage = g.maxPerPage
   406  	}
   407  
   408  	opt := &gitlab.ListProjectIssuesOptions{
   409  		State: &state,
   410  		Sort:  &sort,
   411  		ListOptions: gitlab.ListOptions{
   412  			PerPage: perPage,
   413  			Page:    page,
   414  		},
   415  	}
   416  
   417  	allIssues := make([]*base.Issue, 0, perPage)
   418  
   419  	issues, _, err := g.client.Issues.ListProjectIssues(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
   420  	if err != nil {
   421  		return nil, false, fmt.Errorf("error while listing issues: %w", err)
   422  	}
   423  	for _, issue := range issues {
   424  		labels := make([]*base.Label, 0, len(issue.Labels))
   425  		for _, l := range issue.Labels {
   426  			labels = append(labels, &base.Label{
   427  				Name: l,
   428  			})
   429  		}
   430  
   431  		var milestone string
   432  		if issue.Milestone != nil {
   433  			milestone = issue.Milestone.Title
   434  		}
   435  
   436  		var reactions []*gitlab.AwardEmoji
   437  		awardPage := 1
   438  		for {
   439  			awards, _, err := g.client.AwardEmoji.ListIssueAwardEmoji(g.repoID, issue.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
   440  			if err != nil {
   441  				return nil, false, fmt.Errorf("error while listing issue awards: %w", err)
   442  			}
   443  
   444  			reactions = append(reactions, awards...)
   445  
   446  			if len(awards) < perPage {
   447  				break
   448  			}
   449  
   450  			awardPage++
   451  		}
   452  
   453  		allIssues = append(allIssues, &base.Issue{
   454  			Title:        issue.Title,
   455  			Number:       int64(issue.IID),
   456  			PosterID:     int64(issue.Author.ID),
   457  			PosterName:   issue.Author.Username,
   458  			Content:      issue.Description,
   459  			Milestone:    milestone,
   460  			State:        issue.State,
   461  			Created:      *issue.CreatedAt,
   462  			Labels:       labels,
   463  			Reactions:    g.awardsToReactions(reactions),
   464  			Closed:       issue.ClosedAt,
   465  			IsLocked:     issue.DiscussionLocked,
   466  			Updated:      *issue.UpdatedAt,
   467  			ForeignIndex: int64(issue.IID),
   468  			Context:      gitlabIssueContext{IsMergeRequest: false},
   469  		})
   470  
   471  		// record the issue IID, to be used in GetPullRequests()
   472  		g.iidResolver.recordIssueIID(issue.IID)
   473  	}
   474  
   475  	return allIssues, len(issues) < perPage, nil
   476  }
   477  
   478  // GetComments returns comments according issueNumber
   479  // TODO: figure out how to transfer comment reactions
   480  func (g *GitlabDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) {
   481  	context, ok := commentable.GetContext().(gitlabIssueContext)
   482  	if !ok {
   483  		return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext())
   484  	}
   485  
   486  	allComments := make([]*base.Comment, 0, g.maxPerPage)
   487  
   488  	page := 1
   489  
   490  	for {
   491  		var comments []*gitlab.Discussion
   492  		var resp *gitlab.Response
   493  		var err error
   494  		if !context.IsMergeRequest {
   495  			comments, resp, err = g.client.Discussions.ListIssueDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListIssueDiscussionsOptions{
   496  				Page:    page,
   497  				PerPage: g.maxPerPage,
   498  			}, nil, gitlab.WithContext(g.ctx))
   499  		} else {
   500  			comments, resp, err = g.client.Discussions.ListMergeRequestDiscussions(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListMergeRequestDiscussionsOptions{
   501  				Page:    page,
   502  				PerPage: g.maxPerPage,
   503  			}, nil, gitlab.WithContext(g.ctx))
   504  		}
   505  
   506  		if err != nil {
   507  			return nil, false, fmt.Errorf("error while listing comments: %v %w", g.repoID, err)
   508  		}
   509  		for _, comment := range comments {
   510  			for _, note := range comment.Notes {
   511  				allComments = append(allComments, g.convertNoteToComment(commentable.GetLocalIndex(), note))
   512  			}
   513  		}
   514  		if resp.NextPage == 0 {
   515  			break
   516  		}
   517  		page = resp.NextPage
   518  	}
   519  
   520  	page = 1
   521  	for {
   522  		var stateEvents []*gitlab.StateEvent
   523  		var resp *gitlab.Response
   524  		var err error
   525  		if context.IsMergeRequest {
   526  			stateEvents, resp, err = g.client.ResourceStateEvents.ListMergeStateEvents(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListStateEventsOptions{
   527  				ListOptions: gitlab.ListOptions{
   528  					Page:    page,
   529  					PerPage: g.maxPerPage,
   530  				},
   531  			}, nil, gitlab.WithContext(g.ctx))
   532  		} else {
   533  			stateEvents, resp, err = g.client.ResourceStateEvents.ListIssueStateEvents(g.repoID, int(commentable.GetForeignIndex()), &gitlab.ListStateEventsOptions{
   534  				ListOptions: gitlab.ListOptions{
   535  					Page:    page,
   536  					PerPage: g.maxPerPage,
   537  				},
   538  			}, nil, gitlab.WithContext(g.ctx))
   539  		}
   540  		if err != nil {
   541  			return nil, false, fmt.Errorf("error while listing state events: %v %w", g.repoID, err)
   542  		}
   543  
   544  		for _, stateEvent := range stateEvents {
   545  			comment := &base.Comment{
   546  				IssueIndex: commentable.GetLocalIndex(),
   547  				Index:      int64(stateEvent.ID),
   548  				PosterID:   int64(stateEvent.User.ID),
   549  				PosterName: stateEvent.User.Username,
   550  				Content:    "",
   551  				Created:    *stateEvent.CreatedAt,
   552  			}
   553  			switch stateEvent.State {
   554  			case gitlab.ClosedEventType:
   555  				comment.CommentType = issues_model.CommentTypeClose.String()
   556  			case gitlab.MergedEventType:
   557  				comment.CommentType = issues_model.CommentTypeMergePull.String()
   558  			case gitlab.ReopenedEventType:
   559  				comment.CommentType = issues_model.CommentTypeReopen.String()
   560  			default:
   561  				// Ignore other event types
   562  				continue
   563  			}
   564  			allComments = append(allComments, comment)
   565  		}
   566  
   567  		if resp.NextPage == 0 {
   568  			break
   569  		}
   570  		page = resp.NextPage
   571  	}
   572  
   573  	return allComments, true, nil
   574  }
   575  
   576  var targetBranchChangeRegexp = regexp.MustCompile("^changed target branch from `(.*?)` to `(.*?)`$")
   577  
   578  func (g *GitlabDownloader) convertNoteToComment(localIndex int64, note *gitlab.Note) *base.Comment {
   579  	comment := &base.Comment{
   580  		IssueIndex:  localIndex,
   581  		Index:       int64(note.ID),
   582  		PosterID:    int64(note.Author.ID),
   583  		PosterName:  note.Author.Username,
   584  		PosterEmail: note.Author.Email,
   585  		Content:     note.Body,
   586  		Created:     *note.CreatedAt,
   587  		Meta:        map[string]any{},
   588  	}
   589  
   590  	// Try to find the underlying event of system notes.
   591  	if note.System {
   592  		if match := targetBranchChangeRegexp.FindStringSubmatch(note.Body); match != nil {
   593  			comment.CommentType = issues_model.CommentTypeChangeTargetBranch.String()
   594  			comment.Meta["OldRef"] = match[1]
   595  			comment.Meta["NewRef"] = match[2]
   596  		} else if strings.HasPrefix(note.Body, "enabled an automatic merge") {
   597  			comment.CommentType = issues_model.CommentTypePRScheduledToAutoMerge.String()
   598  		} else if note.Body == "canceled the automatic merge" {
   599  			comment.CommentType = issues_model.CommentTypePRUnScheduledToAutoMerge.String()
   600  		}
   601  	}
   602  
   603  	return comment
   604  }
   605  
   606  // GetPullRequests returns pull requests according page and perPage
   607  func (g *GitlabDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) {
   608  	if perPage > g.maxPerPage {
   609  		perPage = g.maxPerPage
   610  	}
   611  
   612  	view := "simple"
   613  	opt := &gitlab.ListProjectMergeRequestsOptions{
   614  		ListOptions: gitlab.ListOptions{
   615  			PerPage: perPage,
   616  			Page:    page,
   617  		},
   618  		View: &view,
   619  	}
   620  
   621  	allPRs := make([]*base.PullRequest, 0, perPage)
   622  
   623  	prs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.repoID, opt, nil, gitlab.WithContext(g.ctx))
   624  	if err != nil {
   625  		return nil, false, fmt.Errorf("error while listing merge requests: %w", err)
   626  	}
   627  	for _, simplePR := range prs {
   628  		// Load merge request again by itself, as not all fields are populated in the ListProjectMergeRequests endpoint.
   629  		// See https://gitlab.com/gitlab-org/gitlab/-/issues/29620
   630  		pr, _, err := g.client.MergeRequests.GetMergeRequest(g.repoID, simplePR.IID, nil)
   631  		if err != nil {
   632  			return nil, false, fmt.Errorf("error while loading merge request: %w", err)
   633  		}
   634  
   635  		labels := make([]*base.Label, 0, len(pr.Labels))
   636  		for _, l := range pr.Labels {
   637  			labels = append(labels, &base.Label{
   638  				Name: l,
   639  			})
   640  		}
   641  
   642  		var merged bool
   643  		if pr.State == "merged" {
   644  			merged = true
   645  			pr.State = "closed"
   646  		}
   647  
   648  		mergeTime := pr.MergedAt
   649  		if merged && pr.MergedAt == nil {
   650  			mergeTime = pr.UpdatedAt
   651  		}
   652  
   653  		closeTime := pr.ClosedAt
   654  		if merged && pr.ClosedAt == nil {
   655  			closeTime = pr.UpdatedAt
   656  		}
   657  
   658  		mergeCommitSHA := pr.MergeCommitSHA
   659  		if mergeCommitSHA == "" {
   660  			mergeCommitSHA = pr.SquashCommitSHA
   661  		}
   662  
   663  		var locked bool
   664  		if pr.State == "locked" {
   665  			locked = true
   666  		}
   667  
   668  		var milestone string
   669  		if pr.Milestone != nil {
   670  			milestone = pr.Milestone.Title
   671  		}
   672  
   673  		var reactions []*gitlab.AwardEmoji
   674  		awardPage := 1
   675  		for {
   676  			awards, _, err := g.client.AwardEmoji.ListMergeRequestAwardEmoji(g.repoID, pr.IID, &gitlab.ListAwardEmojiOptions{Page: awardPage, PerPage: perPage}, gitlab.WithContext(g.ctx))
   677  			if err != nil {
   678  				return nil, false, fmt.Errorf("error while listing merge requests awards: %w", err)
   679  			}
   680  
   681  			reactions = append(reactions, awards...)
   682  
   683  			if len(awards) < perPage {
   684  				break
   685  			}
   686  
   687  			awardPage++
   688  		}
   689  
   690  		// Generate new PR Numbers by the known Issue Numbers, because they share the same number space in Gitea, but they are independent in Gitlab
   691  		newPRNumber := g.iidResolver.generatePullRequestNumber(pr.IID)
   692  
   693  		allPRs = append(allPRs, &base.PullRequest{
   694  			Title:          pr.Title,
   695  			Number:         newPRNumber,
   696  			PosterName:     pr.Author.Username,
   697  			PosterID:       int64(pr.Author.ID),
   698  			Content:        pr.Description,
   699  			Milestone:      milestone,
   700  			State:          pr.State,
   701  			Created:        *pr.CreatedAt,
   702  			Closed:         closeTime,
   703  			Labels:         labels,
   704  			Merged:         merged,
   705  			MergeCommitSHA: mergeCommitSHA,
   706  			MergedTime:     mergeTime,
   707  			IsLocked:       locked,
   708  			Reactions:      g.awardsToReactions(reactions),
   709  			Head: base.PullRequestBranch{
   710  				Ref:       pr.SourceBranch,
   711  				SHA:       pr.SHA,
   712  				RepoName:  g.repoName,
   713  				OwnerName: pr.Author.Username,
   714  				CloneURL:  pr.WebURL,
   715  			},
   716  			Base: base.PullRequestBranch{
   717  				Ref:       pr.TargetBranch,
   718  				SHA:       pr.DiffRefs.BaseSha,
   719  				RepoName:  g.repoName,
   720  				OwnerName: pr.Author.Username,
   721  			},
   722  			PatchURL:     pr.WebURL + ".patch",
   723  			ForeignIndex: int64(pr.IID),
   724  			Context:      gitlabIssueContext{IsMergeRequest: true},
   725  		})
   726  
   727  		// SECURITY: Ensure that the PR is safe
   728  		_ = CheckAndEnsureSafePR(allPRs[len(allPRs)-1], g.baseURL, g)
   729  	}
   730  
   731  	return allPRs, len(prs) < perPage, nil
   732  }
   733  
   734  // GetReviews returns pull requests review
   735  func (g *GitlabDownloader) GetReviews(reviewable base.Reviewable) ([]*base.Review, error) {
   736  	approvals, resp, err := g.client.MergeRequestApprovals.GetConfiguration(g.repoID, int(reviewable.GetForeignIndex()), gitlab.WithContext(g.ctx))
   737  	if err != nil {
   738  		if resp != nil && resp.StatusCode == http.StatusNotFound {
   739  			log.Error(fmt.Sprintf("GitlabDownloader: while migrating a error occurred: '%s'", err.Error()))
   740  			return []*base.Review{}, nil
   741  		}
   742  		return nil, err
   743  	}
   744  
   745  	var createdAt time.Time
   746  	if approvals.CreatedAt != nil {
   747  		createdAt = *approvals.CreatedAt
   748  	} else if approvals.UpdatedAt != nil {
   749  		createdAt = *approvals.UpdatedAt
   750  	} else {
   751  		createdAt = time.Now()
   752  	}
   753  
   754  	reviews := make([]*base.Review, 0, len(approvals.ApprovedBy))
   755  	for _, user := range approvals.ApprovedBy {
   756  		reviews = append(reviews, &base.Review{
   757  			IssueIndex:   reviewable.GetLocalIndex(),
   758  			ReviewerID:   int64(user.User.ID),
   759  			ReviewerName: user.User.Username,
   760  			CreatedAt:    createdAt,
   761  			// All we get are approvals
   762  			State: base.ReviewStateApproved,
   763  		})
   764  	}
   765  
   766  	return reviews, nil
   767  }
   768  
   769  func (g *GitlabDownloader) awardsToReactions(awards []*gitlab.AwardEmoji) []*base.Reaction {
   770  	result := make([]*base.Reaction, 0, len(awards))
   771  	uniqCheck := make(container.Set[string])
   772  	for _, award := range awards {
   773  		uid := fmt.Sprintf("%s%d", award.Name, award.User.ID)
   774  		if uniqCheck.Add(uid) {
   775  			result = append(result, &base.Reaction{
   776  				UserID:   int64(award.User.ID),
   777  				UserName: award.User.Username,
   778  				Content:  award.Name,
   779  			})
   780  		}
   781  	}
   782  	return result
   783  }