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