code.gitea.io/gitea@v1.21.7/services/migrations/codebase.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 "encoding/xml" 9 "fmt" 10 "net/http" 11 "net/url" 12 "strconv" 13 "strings" 14 "time" 15 16 "code.gitea.io/gitea/modules/log" 17 base "code.gitea.io/gitea/modules/migration" 18 "code.gitea.io/gitea/modules/proxy" 19 "code.gitea.io/gitea/modules/structs" 20 ) 21 22 var ( 23 _ base.Downloader = &CodebaseDownloader{} 24 _ base.DownloaderFactory = &CodebaseDownloaderFactory{} 25 ) 26 27 func init() { 28 RegisterDownloaderFactory(&CodebaseDownloaderFactory{}) 29 } 30 31 // CodebaseDownloaderFactory defines a downloader factory 32 type CodebaseDownloaderFactory struct{} 33 34 // New returns a downloader related to this factory according MigrateOptions 35 func (f *CodebaseDownloaderFactory) New(ctx context.Context, opts base.MigrateOptions) (base.Downloader, error) { 36 u, err := url.Parse(opts.CloneAddr) 37 if err != nil { 38 return nil, err 39 } 40 u.User = nil 41 42 fields := strings.Split(strings.Trim(u.Path, "/"), "/") 43 if len(fields) != 2 { 44 return nil, fmt.Errorf("invalid path: %s", u.Path) 45 } 46 project := fields[0] 47 repoName := strings.TrimSuffix(fields[1], ".git") 48 49 log.Trace("Create Codebase downloader. BaseURL: %v RepoName: %s", u, repoName) 50 51 return NewCodebaseDownloader(ctx, u, project, repoName, opts.AuthUsername, opts.AuthPassword), nil 52 } 53 54 // GitServiceType returns the type of git service 55 func (f *CodebaseDownloaderFactory) GitServiceType() structs.GitServiceType { 56 return structs.CodebaseService 57 } 58 59 type codebaseUser struct { 60 ID int64 `json:"id"` 61 Name string `json:"name"` 62 Email string `json:"email"` 63 } 64 65 // CodebaseDownloader implements a Downloader interface to get repository information 66 // from Codebase 67 type CodebaseDownloader struct { 68 base.NullDownloader 69 ctx context.Context 70 client *http.Client 71 baseURL *url.URL 72 projectURL *url.URL 73 project string 74 repoName string 75 maxIssueIndex int64 76 userMap map[int64]*codebaseUser 77 commitMap map[string]string 78 } 79 80 // SetContext set context 81 func (d *CodebaseDownloader) SetContext(ctx context.Context) { 82 d.ctx = ctx 83 } 84 85 // NewCodebaseDownloader creates a new downloader 86 func NewCodebaseDownloader(ctx context.Context, projectURL *url.URL, project, repoName, username, password string) *CodebaseDownloader { 87 baseURL, _ := url.Parse("https://api3.codebasehq.com") 88 89 downloader := &CodebaseDownloader{ 90 ctx: ctx, 91 baseURL: baseURL, 92 projectURL: projectURL, 93 project: project, 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 proxy.Proxy()(req) 102 }, 103 }, 104 }, 105 userMap: make(map[int64]*codebaseUser), 106 commitMap: make(map[string]string), 107 } 108 109 log.Trace("Create Codebase downloader. BaseURL: %s Project: %s RepoName: %s", baseURL, project, repoName) 110 return downloader 111 } 112 113 // String implements Stringer 114 func (d *CodebaseDownloader) String() string { 115 return fmt.Sprintf("migration from codebase server %s %s/%s", d.baseURL, d.project, d.repoName) 116 } 117 118 func (d *CodebaseDownloader) LogString() string { 119 if d == nil { 120 return "<CodebaseDownloader nil>" 121 } 122 return fmt.Sprintf("<CodebaseDownloader %s %s/%s>", d.baseURL, d.project, d.repoName) 123 } 124 125 // FormatCloneURL add authentication into remote URLs 126 func (d *CodebaseDownloader) FormatCloneURL(opts base.MigrateOptions, remoteAddr string) (string, error) { 127 return opts.CloneAddr, nil 128 } 129 130 func (d *CodebaseDownloader) callAPI(endpoint string, parameter map[string]string, result any) error { 131 u, err := d.baseURL.Parse(endpoint) 132 if err != nil { 133 return err 134 } 135 136 if parameter != nil { 137 query := u.Query() 138 for k, v := range parameter { 139 query.Set(k, v) 140 } 141 u.RawQuery = query.Encode() 142 } 143 144 req, err := http.NewRequestWithContext(d.ctx, "GET", u.String(), nil) 145 if err != nil { 146 return err 147 } 148 req.Header.Add("Accept", "application/xml") 149 150 resp, err := d.client.Do(req) 151 if err != nil { 152 return err 153 } 154 defer resp.Body.Close() 155 156 return xml.NewDecoder(resp.Body).Decode(&result) 157 } 158 159 // GetRepoInfo returns repository information 160 // https://support.codebasehq.com/kb/projects 161 func (d *CodebaseDownloader) GetRepoInfo() (*base.Repository, error) { 162 var rawRepository struct { 163 XMLName xml.Name `xml:"repository"` 164 Name string `xml:"name"` 165 Description string `xml:"description"` 166 Permalink string `xml:"permalink"` 167 CloneURL string `xml:"clone-url"` 168 Source string `xml:"source"` 169 } 170 171 err := d.callAPI( 172 fmt.Sprintf("/%s/%s", d.project, d.repoName), 173 nil, 174 &rawRepository, 175 ) 176 if err != nil { 177 return nil, err 178 } 179 180 return &base.Repository{ 181 Name: rawRepository.Name, 182 Description: rawRepository.Description, 183 CloneURL: rawRepository.CloneURL, 184 OriginalURL: d.projectURL.String(), 185 }, nil 186 } 187 188 // GetMilestones returns milestones 189 // https://support.codebasehq.com/kb/tickets-and-milestones/milestones 190 func (d *CodebaseDownloader) GetMilestones() ([]*base.Milestone, error) { 191 var rawMilestones struct { 192 XMLName xml.Name `xml:"ticketing-milestone"` 193 Type string `xml:"type,attr"` 194 TicketingMilestone []struct { 195 Text string `xml:",chardata"` 196 ID struct { 197 Value int64 `xml:",chardata"` 198 Type string `xml:"type,attr"` 199 } `xml:"id"` 200 Identifier string `xml:"identifier"` 201 Name string `xml:"name"` 202 Deadline struct { 203 Value string `xml:",chardata"` 204 Type string `xml:"type,attr"` 205 } `xml:"deadline"` 206 Description string `xml:"description"` 207 Status string `xml:"status"` 208 } `xml:"ticketing-milestone"` 209 } 210 211 err := d.callAPI( 212 fmt.Sprintf("/%s/milestones", d.project), 213 nil, 214 &rawMilestones, 215 ) 216 if err != nil { 217 return nil, err 218 } 219 220 milestones := make([]*base.Milestone, 0, len(rawMilestones.TicketingMilestone)) 221 for _, milestone := range rawMilestones.TicketingMilestone { 222 var deadline *time.Time 223 if len(milestone.Deadline.Value) > 0 { 224 if val, err := time.Parse("2006-01-02", milestone.Deadline.Value); err == nil { 225 deadline = &val 226 } 227 } 228 229 closed := deadline 230 state := "closed" 231 if milestone.Status == "active" { 232 closed = nil 233 state = "" 234 } 235 236 milestones = append(milestones, &base.Milestone{ 237 Title: milestone.Name, 238 Deadline: deadline, 239 Closed: closed, 240 State: state, 241 }) 242 } 243 return milestones, nil 244 } 245 246 // GetLabels returns labels 247 // https://support.codebasehq.com/kb/tickets-and-milestones/statuses-priorities-and-categories 248 func (d *CodebaseDownloader) GetLabels() ([]*base.Label, error) { 249 var rawTypes struct { 250 XMLName xml.Name `xml:"ticketing-types"` 251 Type string `xml:"type,attr"` 252 TicketingType []struct { 253 ID struct { 254 Value int64 `xml:",chardata"` 255 Type string `xml:"type,attr"` 256 } `xml:"id"` 257 Name string `xml:"name"` 258 } `xml:"ticketing-type"` 259 } 260 261 err := d.callAPI( 262 fmt.Sprintf("/%s/tickets/types", d.project), 263 nil, 264 &rawTypes, 265 ) 266 if err != nil { 267 return nil, err 268 } 269 270 labels := make([]*base.Label, 0, len(rawTypes.TicketingType)) 271 for _, label := range rawTypes.TicketingType { 272 labels = append(labels, &base.Label{ 273 Name: label.Name, 274 Color: "ffffff", 275 }) 276 } 277 return labels, nil 278 } 279 280 type codebaseIssueContext struct { 281 Comments []*base.Comment 282 } 283 284 // GetIssues returns issues, limits are not supported 285 // https://support.codebasehq.com/kb/tickets-and-milestones 286 // https://support.codebasehq.com/kb/tickets-and-milestones/updating-tickets 287 func (d *CodebaseDownloader) GetIssues(page, perPage int) ([]*base.Issue, bool, error) { 288 var rawIssues struct { 289 XMLName xml.Name `xml:"tickets"` 290 Type string `xml:"type,attr"` 291 Ticket []struct { 292 TicketID struct { 293 Value int64 `xml:",chardata"` 294 Type string `xml:"type,attr"` 295 } `xml:"ticket-id"` 296 Summary string `xml:"summary"` 297 TicketType string `xml:"ticket-type"` 298 ReporterID struct { 299 Value int64 `xml:",chardata"` 300 Type string `xml:"type,attr"` 301 } `xml:"reporter-id"` 302 Reporter string `xml:"reporter"` 303 Type struct { 304 Name string `xml:"name"` 305 } `xml:"type"` 306 Status struct { 307 TreatAsClosed struct { 308 Value bool `xml:",chardata"` 309 Type string `xml:"type,attr"` 310 } `xml:"treat-as-closed"` 311 } `xml:"status"` 312 Milestone struct { 313 Name string `xml:"name"` 314 } `xml:"milestone"` 315 UpdatedAt struct { 316 Value time.Time `xml:",chardata"` 317 Type string `xml:"type,attr"` 318 } `xml:"updated-at"` 319 CreatedAt struct { 320 Value time.Time `xml:",chardata"` 321 Type string `xml:"type,attr"` 322 } `xml:"created-at"` 323 } `xml:"ticket"` 324 } 325 326 err := d.callAPI( 327 fmt.Sprintf("/%s/tickets", d.project), 328 nil, 329 &rawIssues, 330 ) 331 if err != nil { 332 return nil, false, err 333 } 334 335 issues := make([]*base.Issue, 0, len(rawIssues.Ticket)) 336 for _, issue := range rawIssues.Ticket { 337 var notes struct { 338 XMLName xml.Name `xml:"ticket-notes"` 339 Type string `xml:"type,attr"` 340 TicketNote []struct { 341 Content string `xml:"content"` 342 CreatedAt struct { 343 Value time.Time `xml:",chardata"` 344 Type string `xml:"type,attr"` 345 } `xml:"created-at"` 346 UpdatedAt struct { 347 Value time.Time `xml:",chardata"` 348 Type string `xml:"type,attr"` 349 } `xml:"updated-at"` 350 ID struct { 351 Value int64 `xml:",chardata"` 352 Type string `xml:"type,attr"` 353 } `xml:"id"` 354 UserID struct { 355 Value int64 `xml:",chardata"` 356 Type string `xml:"type,attr"` 357 } `xml:"user-id"` 358 } `xml:"ticket-note"` 359 } 360 err := d.callAPI( 361 fmt.Sprintf("/%s/tickets/%d/notes", d.project, issue.TicketID.Value), 362 nil, 363 ¬es, 364 ) 365 if err != nil { 366 return nil, false, err 367 } 368 comments := make([]*base.Comment, 0, len(notes.TicketNote)) 369 for _, note := range notes.TicketNote { 370 if len(note.Content) == 0 { 371 continue 372 } 373 poster := d.tryGetUser(note.UserID.Value) 374 comments = append(comments, &base.Comment{ 375 IssueIndex: issue.TicketID.Value, 376 Index: note.ID.Value, 377 PosterID: poster.ID, 378 PosterName: poster.Name, 379 PosterEmail: poster.Email, 380 Content: note.Content, 381 Created: note.CreatedAt.Value, 382 Updated: note.UpdatedAt.Value, 383 }) 384 } 385 if len(comments) == 0 { 386 comments = append(comments, &base.Comment{}) 387 } 388 389 state := "open" 390 if issue.Status.TreatAsClosed.Value { 391 state = "closed" 392 } 393 poster := d.tryGetUser(issue.ReporterID.Value) 394 issues = append(issues, &base.Issue{ 395 Title: issue.Summary, 396 Number: issue.TicketID.Value, 397 PosterName: poster.Name, 398 PosterEmail: poster.Email, 399 Content: comments[0].Content, 400 Milestone: issue.Milestone.Name, 401 State: state, 402 Created: issue.CreatedAt.Value, 403 Updated: issue.UpdatedAt.Value, 404 Labels: []*base.Label{ 405 {Name: issue.Type.Name}, 406 }, 407 ForeignIndex: issue.TicketID.Value, 408 Context: codebaseIssueContext{ 409 Comments: comments[1:], 410 }, 411 }) 412 413 if d.maxIssueIndex < issue.TicketID.Value { 414 d.maxIssueIndex = issue.TicketID.Value 415 } 416 } 417 418 return issues, true, nil 419 } 420 421 // GetComments returns comments 422 func (d *CodebaseDownloader) GetComments(commentable base.Commentable) ([]*base.Comment, bool, error) { 423 context, ok := commentable.GetContext().(codebaseIssueContext) 424 if !ok { 425 return nil, false, fmt.Errorf("unexpected context: %+v", commentable.GetContext()) 426 } 427 428 return context.Comments, true, nil 429 } 430 431 // GetPullRequests returns pull requests 432 // https://support.codebasehq.com/kb/repositories/merge-requests 433 func (d *CodebaseDownloader) GetPullRequests(page, perPage int) ([]*base.PullRequest, bool, error) { 434 var rawMergeRequests struct { 435 XMLName xml.Name `xml:"merge-requests"` 436 Type string `xml:"type,attr"` 437 MergeRequest []struct { 438 ID struct { 439 Value int64 `xml:",chardata"` 440 Type string `xml:"type,attr"` 441 } `xml:"id"` 442 } `xml:"merge-request"` 443 } 444 445 err := d.callAPI( 446 fmt.Sprintf("/%s/%s/merge_requests", d.project, d.repoName), 447 map[string]string{ 448 "query": `"Target Project" is "` + d.repoName + `"`, 449 "offset": strconv.Itoa((page - 1) * perPage), 450 "count": strconv.Itoa(perPage), 451 }, 452 &rawMergeRequests, 453 ) 454 if err != nil { 455 return nil, false, err 456 } 457 458 pullRequests := make([]*base.PullRequest, 0, len(rawMergeRequests.MergeRequest)) 459 for i, mr := range rawMergeRequests.MergeRequest { 460 var rawMergeRequest struct { 461 XMLName xml.Name `xml:"merge-request"` 462 ID struct { 463 Value int64 `xml:",chardata"` 464 Type string `xml:"type,attr"` 465 } `xml:"id"` 466 SourceRef string `xml:"source-ref"` // NOTE: from the documentation these are actually just branches NOT full refs 467 TargetRef string `xml:"target-ref"` // NOTE: from the documentation these are actually just branches NOT full refs 468 Subject string `xml:"subject"` 469 Status string `xml:"status"` 470 UserID struct { 471 Value int64 `xml:",chardata"` 472 Type string `xml:"type,attr"` 473 } `xml:"user-id"` 474 CreatedAt struct { 475 Value time.Time `xml:",chardata"` 476 Type string `xml:"type,attr"` 477 } `xml:"created-at"` 478 UpdatedAt struct { 479 Value time.Time `xml:",chardata"` 480 Type string `xml:"type,attr"` 481 } `xml:"updated-at"` 482 Comments struct { 483 Type string `xml:"type,attr"` 484 Comment []struct { 485 Content string `xml:"content"` 486 ID struct { 487 Value int64 `xml:",chardata"` 488 Type string `xml:"type,attr"` 489 } `xml:"id"` 490 UserID struct { 491 Value int64 `xml:",chardata"` 492 Type string `xml:"type,attr"` 493 } `xml:"user-id"` 494 Action struct { 495 Value string `xml:",chardata"` 496 Nil string `xml:"nil,attr"` 497 } `xml:"action"` 498 CreatedAt struct { 499 Value time.Time `xml:",chardata"` 500 Type string `xml:"type,attr"` 501 } `xml:"created-at"` 502 } `xml:"comment"` 503 } `xml:"comments"` 504 } 505 err := d.callAPI( 506 fmt.Sprintf("/%s/%s/merge_requests/%d", d.project, d.repoName, mr.ID.Value), 507 nil, 508 &rawMergeRequest, 509 ) 510 if err != nil { 511 return nil, false, err 512 } 513 514 number := d.maxIssueIndex + int64(i) + 1 515 516 state := "open" 517 merged := false 518 var closeTime *time.Time 519 var mergedTime *time.Time 520 if rawMergeRequest.Status != "new" { 521 state = "closed" 522 closeTime = &rawMergeRequest.UpdatedAt.Value 523 } 524 525 comments := make([]*base.Comment, 0, len(rawMergeRequest.Comments.Comment)) 526 for _, comment := range rawMergeRequest.Comments.Comment { 527 if len(comment.Content) == 0 { 528 if comment.Action.Value == "merging" { 529 merged = true 530 mergedTime = &comment.CreatedAt.Value 531 } 532 continue 533 } 534 poster := d.tryGetUser(comment.UserID.Value) 535 comments = append(comments, &base.Comment{ 536 IssueIndex: number, 537 Index: comment.ID.Value, 538 PosterID: poster.ID, 539 PosterName: poster.Name, 540 PosterEmail: poster.Email, 541 Content: comment.Content, 542 Created: comment.CreatedAt.Value, 543 Updated: comment.CreatedAt.Value, 544 }) 545 } 546 if len(comments) == 0 { 547 comments = append(comments, &base.Comment{}) 548 } 549 550 poster := d.tryGetUser(rawMergeRequest.UserID.Value) 551 552 pullRequests = append(pullRequests, &base.PullRequest{ 553 Title: rawMergeRequest.Subject, 554 Number: number, 555 PosterName: poster.Name, 556 PosterEmail: poster.Email, 557 Content: comments[0].Content, 558 State: state, 559 Created: rawMergeRequest.CreatedAt.Value, 560 Updated: rawMergeRequest.UpdatedAt.Value, 561 Closed: closeTime, 562 Merged: merged, 563 MergedTime: mergedTime, 564 Head: base.PullRequestBranch{ 565 Ref: rawMergeRequest.SourceRef, 566 SHA: d.getHeadCommit(rawMergeRequest.SourceRef), 567 RepoName: d.repoName, 568 }, 569 Base: base.PullRequestBranch{ 570 Ref: rawMergeRequest.TargetRef, 571 SHA: d.getHeadCommit(rawMergeRequest.TargetRef), 572 RepoName: d.repoName, 573 }, 574 ForeignIndex: rawMergeRequest.ID.Value, 575 Context: codebaseIssueContext{ 576 Comments: comments[1:], 577 }, 578 }) 579 580 // SECURITY: Ensure that the PR is safe 581 _ = CheckAndEnsureSafePR(pullRequests[len(pullRequests)-1], d.baseURL.String(), d) 582 } 583 584 return pullRequests, true, nil 585 } 586 587 func (d *CodebaseDownloader) tryGetUser(userID int64) *codebaseUser { 588 if len(d.userMap) == 0 { 589 var rawUsers struct { 590 XMLName xml.Name `xml:"users"` 591 Type string `xml:"type,attr"` 592 User []struct { 593 EmailAddress string `xml:"email-address"` 594 ID struct { 595 Value int64 `xml:",chardata"` 596 Type string `xml:"type,attr"` 597 } `xml:"id"` 598 LastName string `xml:"last-name"` 599 FirstName string `xml:"first-name"` 600 Username string `xml:"username"` 601 } `xml:"user"` 602 } 603 604 err := d.callAPI( 605 "/users", 606 nil, 607 &rawUsers, 608 ) 609 if err == nil { 610 for _, user := range rawUsers.User { 611 d.userMap[user.ID.Value] = &codebaseUser{ 612 Name: user.Username, 613 Email: user.EmailAddress, 614 } 615 } 616 } 617 } 618 619 user, ok := d.userMap[userID] 620 if !ok { 621 user = &codebaseUser{ 622 Name: fmt.Sprintf("User %d", userID), 623 } 624 d.userMap[userID] = user 625 } 626 627 return user 628 } 629 630 func (d *CodebaseDownloader) getHeadCommit(ref string) string { 631 commitRef, ok := d.commitMap[ref] 632 if !ok { 633 var rawCommits struct { 634 XMLName xml.Name `xml:"commits"` 635 Type string `xml:"type,attr"` 636 Commit []struct { 637 Ref string `xml:"ref"` 638 } `xml:"commit"` 639 } 640 err := d.callAPI( 641 fmt.Sprintf("/%s/%s/commits/%s", d.project, d.repoName, ref), 642 nil, 643 &rawCommits, 644 ) 645 if err == nil && len(rawCommits.Commit) > 0 { 646 commitRef = rawCommits.Commit[0].Ref 647 d.commitMap[ref] = commitRef 648 } 649 } 650 return commitRef 651 }