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 }