github.com/zaquestion/lab@v0.25.1/internal/gitlab/gitlab.go (about) 1 // Package gitlab is an internal wrapper for the go-gitlab package 2 // 3 // Most functions serve to expose debug logging if set and accept a project 4 // name string over an ID. 5 package gitlab 6 7 import ( 8 "crypto/tls" 9 "crypto/x509" 10 "fmt" 11 "io" 12 "io/ioutil" 13 "net" 14 "net/http" 15 "os" 16 "path/filepath" 17 "sort" 18 "strconv" 19 "strings" 20 "time" 21 22 "github.com/pkg/errors" 23 gitlab "github.com/xanzy/go-gitlab" 24 "github.com/zaquestion/lab/internal/git" 25 "github.com/zaquestion/lab/internal/logger" 26 ) 27 28 // Get internal lab logger instance 29 var log = logger.GetInstance() 30 31 // 100 is the maximum allowed by the API 32 const maxItemsPerPage = 100 33 34 var ( 35 // ErrActionRepeated is returned when a GitLab action is executed again. For example 36 // this can be returned when an MR is approved twice. 37 ErrActionRepeated = errors.New("GitLab action repeated") 38 // ErrNotModified is returned when adding an already existing item to a Todo list 39 ErrNotModified = errors.New("Not Modified") 40 // ErrProjectNotFound is returned when a GitLab project cannot be found. 41 ErrProjectNotFound = errors.New("GitLab project not found, verify you have access to the requested resource") 42 // ErrStatusForbidden is returned when attempting to access a GitLab project with insufficient permissions 43 ErrStatusForbidden = errors.New("Insufficient permissions for GitLab project") 44 ) 45 46 var ( 47 lab *gitlab.Client 48 host string 49 user string 50 token string 51 ) 52 53 // Host exposes the GitLab scheme://hostname used to interact with the API 54 func Host() string { 55 return host 56 } 57 58 // User exposes the configured GitLab user 59 func User() string { 60 return user 61 } 62 63 // UserID get the current user ID from gitlab server 64 func UserID() (int, error) { 65 u, _, err := lab.Users.CurrentUser() 66 if err != nil { 67 return 0, err 68 } 69 return u.ID, nil 70 } 71 72 // Init initializes a gitlab client for use throughout lab. 73 func Init(_host, _user, _token string, allowInsecure bool) { 74 if len(_host) > 0 && _host[len(_host)-1] == '/' { 75 _host = _host[:len(_host)-1] 76 } 77 host = _host 78 user = _user 79 token = _token 80 81 httpClient := &http.Client{ 82 Transport: &http.Transport{ 83 Proxy: http.ProxyFromEnvironment, 84 DialContext: (&net.Dialer{ 85 Timeout: 30 * time.Second, 86 KeepAlive: 30 * time.Second, 87 DualStack: true, 88 }).DialContext, 89 ForceAttemptHTTP2: true, 90 MaxIdleConns: 100, 91 IdleConnTimeout: 90 * time.Second, 92 TLSHandshakeTimeout: 10 * time.Second, 93 ExpectContinueTimeout: 1 * time.Second, 94 TLSClientConfig: &tls.Config{ 95 InsecureSkipVerify: allowInsecure, 96 }, 97 }, 98 } 99 100 lab, _ = gitlab.NewClient(token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(host+"/api/v4"), gitlab.WithCustomLeveledLogger(log)) 101 } 102 103 // InitWithCustomCA open the HTTP client using a custom CA file (a self signed 104 // one for instance) instead of relying only on those installed in the current 105 // system database 106 func InitWithCustomCA(_host, _user, _token, caFile string) error { 107 if len(_host) > 0 && _host[len(_host)-1] == '/' { 108 _host = _host[:len(_host)-1] 109 } 110 host = _host 111 user = _user 112 token = _token 113 114 caCert, err := ioutil.ReadFile(caFile) 115 if err != nil { 116 return err 117 } 118 // use system cert pool as a baseline 119 caCertPool, err := x509.SystemCertPool() 120 if err != nil { 121 return err 122 } 123 caCertPool.AppendCertsFromPEM(caCert) 124 125 httpClient := &http.Client{ 126 Transport: &http.Transport{ 127 Proxy: http.ProxyFromEnvironment, 128 DialContext: (&net.Dialer{ 129 Timeout: 30 * time.Second, 130 KeepAlive: 30 * time.Second, 131 DualStack: true, 132 }).DialContext, 133 ForceAttemptHTTP2: true, 134 MaxIdleConns: 100, 135 IdleConnTimeout: 90 * time.Second, 136 TLSHandshakeTimeout: 10 * time.Second, 137 ExpectContinueTimeout: 1 * time.Second, 138 TLSClientConfig: &tls.Config{ 139 RootCAs: caCertPool, 140 }, 141 }, 142 } 143 144 lab, _ = gitlab.NewClient(token, gitlab.WithHTTPClient(httpClient), gitlab.WithBaseURL(host+"/api/v4")) 145 return nil 146 } 147 148 func parseID(id interface{}) (string, error) { 149 var strID string 150 151 switch v := id.(type) { 152 case int: 153 strID = strconv.Itoa(v) 154 case string: 155 strID = v 156 default: 157 return "", fmt.Errorf("unknown id type %#v", id) 158 } 159 160 return strID, nil 161 } 162 163 // Defines filepath for default GitLab templates 164 const ( 165 TmplMR = "merge_request_templates/default.md" 166 TmplIssue = "issue_templates/default.md" 167 ) 168 169 // LoadGitLabTmpl loads gitlab templates for use in creating Issues and MRs 170 // 171 // https://gitlab.com/help/user/project/description_templates.md#setting-a-default-template-for-issues-and-merge-requests 172 func LoadGitLabTmpl(tmplName string) string { 173 wd, err := git.WorkingDir() 174 if err != nil { 175 log.Fatal(err) 176 } 177 178 tmplFile := filepath.Join(wd, ".gitlab", tmplName) 179 content, err := ioutil.ReadFile(tmplFile) 180 if err != nil { 181 if errors.Is(err, os.ErrNotExist) { 182 return "" 183 } 184 log.Fatal(err) 185 } 186 187 return strings.TrimSpace(string(content)) 188 } 189 190 var localProjects map[string]*gitlab.Project = make(map[string]*gitlab.Project) 191 192 // GetProject looks up a Gitlab project by ID. 193 func GetProject(projID interface{}) (*gitlab.Project, error) { 194 target, resp, err := lab.Projects.GetProject(projID, nil) 195 if resp != nil && resp.StatusCode == http.StatusNotFound { 196 return nil, ErrProjectNotFound 197 } 198 if err != nil { 199 return nil, err 200 } 201 return target, nil 202 } 203 204 // FindProject looks up the Gitlab project. If the namespace is not provided in 205 // the project string it will search for projects in the users namespace 206 func FindProject(projID interface{}) (*gitlab.Project, error) { 207 var ( 208 id string 209 search string 210 ) 211 212 switch v := projID.(type) { 213 case int: 214 // If the project number is used directly, don't "guess" anything 215 id = strconv.Itoa(v) 216 search = id 217 case string: 218 id = v 219 search = id 220 // If the project name is used, check if it already has the 221 // namespace (already have a slash '/' in the name) or try to guess 222 // it's on user's own namespace. 223 if !strings.Contains(id, "/") { 224 search = user + "/" + id 225 } 226 } 227 228 if target, ok := localProjects[id]; ok { 229 return target, nil 230 } 231 232 target, err := GetProject(search) 233 if err != nil { 234 return nil, err 235 } 236 237 // fwiw, I feel bad about this 238 localProjects[id] = target 239 240 return target, nil 241 } 242 243 // Fork creates a user fork of a GitLab project using the specified protocol 244 func Fork(projID interface{}, opts *gitlab.ForkProjectOptions, useHTTP bool, wait bool) (string, error) { 245 var id string 246 247 switch v := projID.(type) { 248 case int: 249 // If numeric ID, we need the complete name with namespace 250 p, err := FindProject(v) 251 if err != nil { 252 return "", err 253 } 254 id = p.NameWithNamespace 255 case string: 256 id = v 257 // Check if the ID passed already contains the namespace/path that 258 // we need. 259 if !strings.Contains(id, "/") { 260 // Is it a numeric ID passed as string? 261 if _, err := strconv.Atoi(id); err != nil { 262 return "", errors.New("remote must include namespace") 263 } 264 265 // Do the same as done in 'case int' for numeric ID passed as 266 // string 267 p, err := FindProject(id) 268 if err != nil { 269 return "", err 270 } 271 id = p.NameWithNamespace 272 } 273 } 274 275 parts := strings.Split(id, "/") 276 277 // See if a fork already exists in the destination 278 name := parts[len(parts)-1] 279 namespace := "" 280 if opts != nil { 281 var ( 282 optName = *(opts.Name) 283 optNamespace = *(opts.Namespace) 284 optPath = *(opts.Path) 285 ) 286 287 if optNamespace != "" { 288 namespace = optNamespace + "/" 289 } 290 // Project name takes precedence over path for finding a project 291 // on Gitlab through API 292 if optName != "" { 293 name = optName 294 } else if optPath != "" { 295 name = optPath 296 } else { 297 opts.Name = gitlab.String(name) 298 } 299 } 300 301 target, err := FindProject(namespace + name) 302 if err == nil { 303 // Check if it isn't the same project being requested 304 if target.PathWithNamespace == projID { 305 errMsg := "not possible to fork a project from the same namespace and name" 306 return "", errors.New(errMsg) 307 } 308 309 // Check if it isn't a non-fork project, meaning the user has 310 // access to a project with same namespace/name 311 if target.ForkedFromProject == nil { 312 errMsg := fmt.Sprintf("\"%s\" project already taken\n", target.PathWithNamespace) 313 return "", errors.New(errMsg) 314 } 315 316 // Check if it isn't already a fork for another project 317 if target.ForkedFromProject != nil && 318 target.ForkedFromProject.PathWithNamespace != projID { 319 errMsg := fmt.Sprintf("\"%s\" fork already taken for a different project", 320 target.PathWithNamespace) 321 return "", errors.New(errMsg) 322 } 323 324 // Project already forked and found 325 urlToRepo := target.SSHURLToRepo 326 if useHTTP { 327 urlToRepo = target.HTTPURLToRepo 328 } 329 return urlToRepo, nil 330 } else if err != nil && err != ErrProjectNotFound { 331 return "", err 332 } 333 334 target, err = FindProject(projID) 335 if err != nil { 336 return "", err 337 } 338 339 // Now that we have the "wait" opt, don't let the user in the hope that 340 // something is running. 341 fmt.Printf("Forking %s project...\n", projID) 342 fork, _, err := lab.Projects.ForkProject(target.ID, opts) 343 if err != nil { 344 return "", err 345 } 346 347 // Busy-wait approach for checking the import_status of the fork. 348 // References: 349 // https://docs.gitlab.com/ce/api/projects.html#fork-project 350 // https://docs.gitlab.com/ee/api/project_import_export.html#import-status 351 status, _, err := lab.ProjectImportExport.ImportStatus(fork.ID, nil) 352 if err != nil { 353 log.Infof("Impossible to get fork status: %s\n", err) 354 } else { 355 if wait { 356 for { 357 if status.ImportStatus == "finished" { 358 break 359 } 360 status, _, err = lab.ProjectImportExport.ImportStatus(fork.ID, nil) 361 if err != nil { 362 log.Fatal(err) 363 } 364 time.Sleep(2 * time.Second) 365 } 366 } else if status.ImportStatus != "finished" { 367 err = errors.New("not finished") 368 } 369 } 370 371 urlToRepo := fork.SSHURLToRepo 372 if useHTTP { 373 urlToRepo = fork.HTTPURLToRepo 374 } 375 return urlToRepo, err 376 } 377 378 // MRCreate opens a merge request on GitLab 379 func MRCreate(projID interface{}, opts *gitlab.CreateMergeRequestOptions) (string, error) { 380 mr, _, err := lab.MergeRequests.CreateMergeRequest(projID, opts) 381 if err != nil { 382 return "", err 383 } 384 return mr.WebURL, nil 385 } 386 387 // MRCreateDiscussion creates a discussion on a merge request on GitLab 388 func MRCreateDiscussion(projID interface{}, id int, opts *gitlab.CreateMergeRequestDiscussionOptions) (string, error) { 389 discussion, _, err := lab.Discussions.CreateMergeRequestDiscussion(projID, id, opts) 390 if err != nil { 391 return "", err 392 } 393 394 // Unlike MR, Note has no WebURL property, so we have to create it 395 // ourselves from the project, noteable id and note id 396 note := discussion.Notes[0] 397 398 p, err := FindProject(projID) 399 if err != nil { 400 return "", err 401 } 402 return fmt.Sprintf("%s/merge_requests/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 403 } 404 405 // MRUpdate edits an merge request on a GitLab project 406 func MRUpdate(projID interface{}, id int, opts *gitlab.UpdateMergeRequestOptions) (string, error) { 407 mr, _, err := lab.MergeRequests.UpdateMergeRequest(projID, id, opts) 408 if err != nil { 409 return "", err 410 } 411 412 return mr.WebURL, nil 413 } 414 415 // MRDelete deletes an merge request on a GitLab project 416 func MRDelete(projID interface{}, id int) error { 417 resp, err := lab.MergeRequests.DeleteMergeRequest(projID, id) 418 if resp != nil && resp.StatusCode == http.StatusForbidden { 419 return ErrStatusForbidden 420 } 421 if err != nil { 422 return err 423 } 424 return nil 425 } 426 427 // MRCreateNote adds a note to a merge request on GitLab 428 func MRCreateNote(projID interface{}, id int, opts *gitlab.CreateMergeRequestNoteOptions) (string, error) { 429 note, _, err := lab.Notes.CreateMergeRequestNote(projID, id, opts) 430 if err != nil { 431 return "", err 432 } 433 434 // Unlike MR, Note has no WebURL property, so we have to create it 435 // ourselves from the project, noteable id and note id 436 p, err := FindProject(projID) 437 if err != nil { 438 return "", err 439 } 440 return fmt.Sprintf("%s/merge_requests/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 441 } 442 443 // MRGet retrieves the merge request from GitLab project 444 func MRGet(projID interface{}, id int) (*gitlab.MergeRequest, error) { 445 mr, _, err := lab.MergeRequests.GetMergeRequest(projID, id, nil) 446 if err != nil { 447 return nil, err 448 } 449 450 return mr, nil 451 } 452 453 // MRList lists the MRs on a GitLab project 454 func MRList(projID interface{}, opts gitlab.ListProjectMergeRequestsOptions, n int) ([]*gitlab.MergeRequest, error) { 455 if n == -1 { 456 n = maxItemsPerPage 457 } 458 459 var list []*gitlab.MergeRequest 460 for len(list) < n { 461 opts.PerPage = n - len(list) 462 mrs, resp, err := lab.MergeRequests.ListProjectMergeRequests(projID, &opts) 463 if err != nil { 464 return nil, err 465 } 466 list = append(list, mrs...) 467 468 var ok bool 469 if opts.Page, ok = hasNextPage(resp); !ok { 470 break 471 } 472 } 473 474 return list, nil 475 } 476 477 // MRClose closes an mr on a GitLab project 478 func MRClose(projID interface{}, id int) error { 479 mr, _, err := lab.MergeRequests.GetMergeRequest(projID, id, nil) 480 if err != nil { 481 return err 482 } 483 if mr.State == "closed" { 484 return fmt.Errorf("mr already closed") 485 } 486 _, _, err = lab.MergeRequests.UpdateMergeRequest(projID, int(id), &gitlab.UpdateMergeRequestOptions{ 487 StateEvent: gitlab.String("close"), 488 }) 489 if err != nil { 490 return err 491 } 492 return nil 493 } 494 495 // MRReopen reopen an already close mr on a GitLab project 496 func MRReopen(projID interface{}, id int) error { 497 mr, _, err := lab.MergeRequests.GetMergeRequest(projID, id, nil) 498 if err != nil { 499 return err 500 } 501 if mr.State == "opened" { 502 return fmt.Errorf("mr not closed") 503 } 504 _, _, err = lab.MergeRequests.UpdateMergeRequest(projID, int(id), &gitlab.UpdateMergeRequestOptions{ 505 StateEvent: gitlab.String("reopen"), 506 }) 507 if err != nil { 508 return err 509 } 510 return nil 511 } 512 513 // MRListDiscussions retrieves the discussions (aka notes & comments) for a merge request 514 func MRListDiscussions(projID interface{}, id int) ([]*gitlab.Discussion, error) { 515 discussions := []*gitlab.Discussion{} 516 opt := &gitlab.ListMergeRequestDiscussionsOptions{ 517 // 100 is the maximum allowed by the API 518 PerPage: maxItemsPerPage, 519 } 520 521 for { 522 // get a page of discussions from the API ... 523 d, resp, err := lab.Discussions.ListMergeRequestDiscussions(projID, id, opt) 524 if err != nil { 525 return nil, err 526 } 527 528 // ... and add them to our collection of discussions 529 discussions = append(discussions, d...) 530 531 // if we've seen all the pages, then we can break here. 532 // otherwise, update the page number to get the next page. 533 var ok bool 534 if opt.Page, ok = hasNextPage(resp); !ok { 535 break 536 } 537 } 538 539 return discussions, nil 540 } 541 542 // MRRebase merges an mr on a GitLab project 543 func MRRebase(projID interface{}, id int) error { 544 _, err := lab.MergeRequests.RebaseMergeRequest(projID, int(id)) 545 if err != nil { 546 return err 547 } 548 return nil 549 } 550 551 // MRMerge merges an mr on a GitLab project 552 func MRMerge(projID interface{}, id int, opts *gitlab.AcceptMergeRequestOptions) error { 553 _, _, err := lab.MergeRequests.AcceptMergeRequest(projID, int(id), opts) 554 if err != nil { 555 return err 556 } 557 return nil 558 } 559 560 // MRApprove approves an mr on a GitLab project 561 func MRApprove(projID interface{}, id int) error { 562 _, resp, err := lab.MergeRequestApprovals.ApproveMergeRequest(projID, id, &gitlab.ApproveMergeRequestOptions{}) 563 if resp != nil && resp.StatusCode == http.StatusForbidden { 564 return ErrStatusForbidden 565 } 566 if resp != nil && resp.StatusCode == http.StatusUnauthorized { 567 // returns 401 if the MR has already been approved 568 return ErrActionRepeated 569 } 570 if err != nil { 571 return err 572 } 573 return nil 574 } 575 576 // MRUnapprove Unapproves a previously approved mr on a GitLab project 577 func MRUnapprove(projID interface{}, id int) error { 578 resp, err := lab.MergeRequestApprovals.UnapproveMergeRequest(projID, id, nil) 579 if resp != nil && resp.StatusCode == http.StatusForbidden { 580 return ErrStatusForbidden 581 } 582 if resp != nil && resp.StatusCode == http.StatusNotFound { 583 // returns 404 if the MR has already been unapproved 584 return ErrActionRepeated 585 } 586 if err != nil { 587 return err 588 } 589 return nil 590 } 591 592 // MRSubscribe subscribes to an mr on a GitLab project 593 func MRSubscribe(projID interface{}, id int) error { 594 _, resp, err := lab.MergeRequests.SubscribeToMergeRequest(projID, id, nil) 595 if resp != nil && resp.StatusCode == http.StatusNotModified { 596 return errors.New("Already subscribed") 597 } 598 if err != nil { 599 return err 600 } 601 return nil 602 } 603 604 // MRUnsubscribe unsubscribes from a previously mr on a GitLab project 605 func MRUnsubscribe(projID interface{}, id int) error { 606 _, resp, err := lab.MergeRequests.UnsubscribeFromMergeRequest(projID, id, nil) 607 if resp != nil && resp.StatusCode == http.StatusNotModified { 608 return errors.New("Not subscribed") 609 } 610 if err != nil { 611 return err 612 } 613 return nil 614 } 615 616 // MRThumbUp places a thumb up/down on a merge request 617 func MRThumbUp(projID interface{}, id int) error { 618 _, _, err := lab.AwardEmoji.CreateMergeRequestAwardEmoji(projID, id, &gitlab.CreateAwardEmojiOptions{ 619 Name: "thumbsup", 620 }) 621 if err != nil { 622 return err 623 } 624 return nil 625 } 626 627 // MRThumbDown places a thumb up/down on a merge request 628 func MRThumbDown(projID interface{}, id int) error { 629 _, _, err := lab.AwardEmoji.CreateMergeRequestAwardEmoji(projID, id, &gitlab.CreateAwardEmojiOptions{ 630 Name: "thumbsdown", 631 }) 632 if err != nil { 633 return err 634 } 635 return nil 636 } 637 638 // IssueCreate opens a new issue on a GitLab project 639 func IssueCreate(projID interface{}, opts *gitlab.CreateIssueOptions) (string, error) { 640 mr, _, err := lab.Issues.CreateIssue(projID, opts) 641 if err != nil { 642 return "", err 643 } 644 return mr.WebURL, nil 645 } 646 647 // IssueUpdate edits an issue on a GitLab project 648 func IssueUpdate(projID interface{}, id int, opts *gitlab.UpdateIssueOptions) (string, error) { 649 issue, _, err := lab.Issues.UpdateIssue(projID, id, opts) 650 if err != nil { 651 return "", err 652 } 653 return issue.WebURL, nil 654 } 655 656 // IssueCreateNote creates a new note on an issue and returns the note URL 657 func IssueCreateNote(projID interface{}, id int, opts *gitlab.CreateIssueNoteOptions) (string, error) { 658 note, _, err := lab.Notes.CreateIssueNote(projID, id, opts) 659 if err != nil { 660 return "", err 661 } 662 663 // Unlike Issue, Note has no WebURL property, so we have to create it 664 // ourselves from the project, noteable id and note id 665 p, err := FindProject(projID) 666 if err != nil { 667 return "", err 668 } 669 return fmt.Sprintf("%s/issues/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 670 } 671 672 // IssueGet retrieves the issue information from a GitLab project 673 func IssueGet(projID interface{}, id int) (*gitlab.Issue, error) { 674 issue, _, err := lab.Issues.GetIssue(projID, id) 675 if err != nil { 676 return nil, err 677 } 678 679 return issue, nil 680 } 681 682 // IssueList gets a list of issues on a GitLab Project 683 func IssueList(projID interface{}, opts gitlab.ListProjectIssuesOptions, n int) ([]*gitlab.Issue, error) { 684 if n == -1 { 685 n = maxItemsPerPage 686 } 687 688 var list []*gitlab.Issue 689 for len(list) < n { 690 opts.PerPage = n - len(list) 691 issues, resp, err := lab.Issues.ListProjectIssues(projID, &opts) 692 if err != nil { 693 return nil, err 694 } 695 list = append(list, issues...) 696 697 var ok bool 698 if opts.Page, ok = hasNextPage(resp); !ok { 699 break 700 } 701 } 702 return list, nil 703 } 704 705 // IssueClose closes an issue on a GitLab project 706 func IssueClose(projID interface{}, id int) error { 707 issue, _, err := lab.Issues.GetIssue(projID, id) 708 if err != nil { 709 return err 710 } 711 if issue.State == "closed" { 712 return fmt.Errorf("issue already closed") 713 } 714 _, _, err = lab.Issues.UpdateIssue(projID, id, &gitlab.UpdateIssueOptions{ 715 StateEvent: gitlab.String("close"), 716 }) 717 if err != nil { 718 return err 719 } 720 return nil 721 } 722 723 // IssueDuplicate closes an issue as duplicate of another 724 func IssueDuplicate(projID interface{}, id int, dupID interface{}) error { 725 dID, err := parseID(dupID) 726 if err != nil { 727 return err 728 } 729 730 // Not exposed in API, go through quick action 731 body := "/duplicate " + dID 732 733 _, _, err = lab.Notes.CreateIssueNote(projID, id, &gitlab.CreateIssueNoteOptions{ 734 Body: &body, 735 }) 736 if err != nil { 737 return errors.Errorf("Failed to close issue #%d as duplicate of %s", id, dID) 738 } 739 740 issue, _, err := lab.Issues.GetIssue(projID, id) 741 if issue == nil || issue.State != "closed" { 742 return errors.Errorf("Failed to close issue #%d as duplicate of %s", id, dID) 743 } 744 return nil 745 } 746 747 // IssueReopen reopens a closed issue 748 func IssueReopen(projID interface{}, id int) error { 749 issue, _, err := lab.Issues.GetIssue(projID, id) 750 if err != nil { 751 return err 752 } 753 if issue.State == "opened" { 754 return fmt.Errorf("issue not closed") 755 } 756 _, _, err = lab.Issues.UpdateIssue(projID, id, &gitlab.UpdateIssueOptions{ 757 StateEvent: gitlab.String("reopen"), 758 }) 759 if err != nil { 760 return err 761 } 762 return nil 763 } 764 765 // IssueListDiscussions retrieves the discussions (aka notes & comments) for an issue 766 func IssueListDiscussions(projID interface{}, id int) ([]*gitlab.Discussion, error) { 767 discussions := []*gitlab.Discussion{} 768 opt := &gitlab.ListIssueDiscussionsOptions{ 769 // 100 is the maximum allowed by the API 770 PerPage: maxItemsPerPage, 771 } 772 773 for { 774 // get a page of discussions from the API ... 775 d, resp, err := lab.Discussions.ListIssueDiscussions(projID, id, opt) 776 if err != nil { 777 return nil, err 778 } 779 780 // ... and add them to our collection of discussions 781 discussions = append(discussions, d...) 782 783 // if we've seen all the pages, then we can break here. 784 // otherwise, update the page number to get the next page. 785 var ok bool 786 if opt.Page, ok = hasNextPage(resp); !ok { 787 break 788 } 789 } 790 791 return discussions, nil 792 } 793 794 // IssueSubscribe subscribes to an issue on a GitLab project 795 func IssueSubscribe(projID interface{}, id int) error { 796 _, resp, err := lab.Issues.SubscribeToIssue(projID, id, nil) 797 if resp != nil && resp.StatusCode == http.StatusNotModified { 798 return errors.New("Already subscribed") 799 } 800 if err != nil { 801 return err 802 } 803 return nil 804 } 805 806 // IssueUnsubscribe unsubscribes from an issue on a GitLab project 807 func IssueUnsubscribe(projID interface{}, id int) error { 808 _, resp, err := lab.Issues.UnsubscribeFromIssue(projID, id, nil) 809 if resp != nil && resp.StatusCode == http.StatusNotModified { 810 return errors.New("Not subscribed") 811 } 812 if err != nil { 813 return err 814 } 815 return nil 816 } 817 818 // GetCommit returns top Commit by ref (hash, branch or tag). 819 func GetCommit(projID interface{}, ref string) (*gitlab.Commit, error) { 820 c, _, err := lab.Commits.GetCommit(projID, ref) 821 if err != nil { 822 return nil, err 823 } 824 return c, nil 825 } 826 827 // LabelList gets a list of labels on a GitLab Project 828 func LabelList(projID interface{}) ([]*gitlab.Label, error) { 829 labels := []*gitlab.Label{} 830 opt := &gitlab.ListLabelsOptions{ 831 ListOptions: gitlab.ListOptions{ 832 PerPage: maxItemsPerPage, 833 }, 834 } 835 836 for { 837 l, resp, err := lab.Labels.ListLabels(projID, opt) 838 if err != nil { 839 return nil, err 840 } 841 842 labels = append(labels, l...) 843 844 // if we've seen all the pages, then we can break here 845 // otherwise, update the page number to get the next page. 846 var ok bool 847 if opt.Page, ok = hasNextPage(resp); !ok { 848 break 849 } 850 } 851 852 return labels, nil 853 } 854 855 // LabelCreate creates a new project label 856 func LabelCreate(projID interface{}, opts *gitlab.CreateLabelOptions) error { 857 _, _, err := lab.Labels.CreateLabel(projID, opts) 858 return err 859 } 860 861 // LabelDelete removes a project label 862 func LabelDelete(projID, name string) error { 863 _, err := lab.Labels.DeleteLabel(projID, &gitlab.DeleteLabelOptions{ 864 Name: &name, 865 }) 866 return err 867 } 868 869 // BranchList get all branches from the project that somehow matches the 870 // requested options 871 func BranchList(projID interface{}, opts *gitlab.ListBranchesOptions) ([]*gitlab.Branch, error) { 872 branches := []*gitlab.Branch{} 873 for { 874 bList, resp, err := lab.Branches.ListBranches(projID, opts) 875 if err != nil { 876 return nil, err 877 } 878 branches = append(branches, bList...) 879 880 var ok bool 881 if opts.Page, ok = hasNextPage(resp); !ok { 882 break 883 } 884 } 885 886 return branches, nil 887 } 888 889 // MilestoneGet get a specific milestone from the list of available ones 890 func MilestoneGet(projID interface{}, name string) (*gitlab.Milestone, error) { 891 opts := &gitlab.ListMilestonesOptions{ 892 Title: &name, 893 } 894 milestones, _ := MilestoneList(projID, opts) 895 896 switch len(milestones) { 897 case 1: 898 return milestones[0], nil 899 case 0: 900 return nil, errors.Errorf("Milestone '%s' not found", name) 901 default: 902 return nil, errors.Errorf("Milestone '%s' is ambiguous", name) 903 } 904 } 905 906 // MilestoneList gets a list of milestones on a GitLab Project 907 func MilestoneList(projID interface{}, opt *gitlab.ListMilestonesOptions) ([]*gitlab.Milestone, error) { 908 milestones := []*gitlab.Milestone{} 909 for { 910 m, resp, err := lab.Milestones.ListMilestones(projID, opt) 911 if err != nil { 912 return nil, err 913 } 914 915 milestones = append(milestones, m...) 916 917 // if we've seen all the pages, then we can break here. 918 // otherwise, update the page number to get the next page. 919 var ok bool 920 if opt.Page, ok = hasNextPage(resp); !ok { 921 break 922 } 923 } 924 925 p, err := FindProject(projID) 926 if err != nil { 927 return nil, err 928 } 929 if p.Namespace.Kind != "group" { 930 return milestones, nil 931 } 932 933 // get inherited milestones from group; in the future, we'll be able to use the 934 // IncludeParentMilestones option with ListMilestones() 935 includeParents := true 936 gopt := &gitlab.ListGroupMilestonesOptions{ 937 IIDs: opt.IIDs, 938 Title: opt.Title, 939 State: opt.State, 940 Search: opt.Search, 941 IncludeParentMilestones: &includeParents, 942 } 943 944 for { 945 groupMilestones, resp, err := lab.GroupMilestones.ListGroupMilestones(p.Namespace.ID, gopt) 946 if err != nil { 947 return nil, err 948 } 949 950 for _, m := range groupMilestones { 951 milestones = append(milestones, &gitlab.Milestone{ 952 ID: m.ID, 953 IID: m.IID, 954 Title: m.Title, 955 Description: m.Description, 956 StartDate: m.StartDate, 957 DueDate: m.DueDate, 958 State: m.State, 959 UpdatedAt: m.UpdatedAt, 960 CreatedAt: m.CreatedAt, 961 Expired: m.Expired, 962 }) 963 } 964 965 // if we've seen all the pages, then we can break here 966 // otherwise, update the page number to get the next page. 967 var ok bool 968 if gopt.Page, ok = hasNextPage(resp); !ok { 969 break 970 } 971 } 972 973 return milestones, nil 974 } 975 976 // MilestoneCreate creates a new project milestone 977 func MilestoneCreate(projID interface{}, opts *gitlab.CreateMilestoneOptions) error { 978 _, _, err := lab.Milestones.CreateMilestone(projID, opts) 979 return err 980 } 981 982 // MilestoneDelete deletes a project milestone 983 func MilestoneDelete(projID, name string) error { 984 milestone, err := MilestoneGet(projID, name) 985 if err != nil { 986 return err 987 } 988 989 _, err = lab.Milestones.DeleteMilestone(milestone.ProjectID, milestone.ID) 990 return err 991 } 992 993 // ProjectSnippetCreate creates a snippet in a project 994 func ProjectSnippetCreate(projID interface{}, opts *gitlab.CreateProjectSnippetOptions) (*gitlab.Snippet, error) { 995 snip, _, err := lab.ProjectSnippets.CreateSnippet(projID, opts) 996 if err != nil { 997 return nil, err 998 } 999 1000 return snip, nil 1001 } 1002 1003 // ProjectSnippetDelete deletes a project snippet 1004 func ProjectSnippetDelete(projID interface{}, id int) error { 1005 _, err := lab.ProjectSnippets.DeleteSnippet(projID, id) 1006 return err 1007 } 1008 1009 // ProjectSnippetList lists snippets on a project 1010 func ProjectSnippetList(projID interface{}, opts gitlab.ListProjectSnippetsOptions, n int) ([]*gitlab.Snippet, error) { 1011 if n == -1 { 1012 n = maxItemsPerPage 1013 } 1014 1015 var list []*gitlab.Snippet 1016 for len(list) < n { 1017 opts.PerPage = n - len(list) 1018 snips, resp, err := lab.ProjectSnippets.ListSnippets(projID, &opts) 1019 if err != nil { 1020 return nil, err 1021 } 1022 list = append(list, snips...) 1023 1024 var ok bool 1025 if opts.Page, ok = hasNextPage(resp); !ok { 1026 break 1027 } 1028 } 1029 1030 return list, nil 1031 } 1032 1033 // SnippetCreate creates a personal snippet 1034 func SnippetCreate(opts *gitlab.CreateSnippetOptions) (*gitlab.Snippet, error) { 1035 snip, _, err := lab.Snippets.CreateSnippet(opts) 1036 if err != nil { 1037 return nil, err 1038 } 1039 1040 return snip, nil 1041 } 1042 1043 // SnippetDelete deletes a personal snippet 1044 func SnippetDelete(id int) error { 1045 _, err := lab.Snippets.DeleteSnippet(id) 1046 return err 1047 } 1048 1049 // SnippetList lists snippets on a project 1050 func SnippetList(opts gitlab.ListSnippetsOptions, n int) ([]*gitlab.Snippet, error) { 1051 if n == -1 { 1052 n = maxItemsPerPage 1053 } 1054 1055 var list []*gitlab.Snippet 1056 for len(list) < n { 1057 opts.PerPage = n - len(list) 1058 snips, resp, err := lab.Snippets.ListSnippets(&opts) 1059 if err != nil { 1060 return nil, err 1061 } 1062 list = append(list, snips...) 1063 1064 var ok bool 1065 if opts.Page, ok = hasNextPage(resp); !ok { 1066 break 1067 } 1068 } 1069 1070 return list, nil 1071 } 1072 1073 // Lint validates .gitlab-ci.yml contents 1074 func Lint(content string) (bool, error) { 1075 lint, _, err := lab.Validate.Lint(content) 1076 if err != nil { 1077 return false, err 1078 } 1079 if len(lint.Errors) > 0 { 1080 return false, errors.New(strings.Join(lint.Errors, " - ")) 1081 } 1082 return lint.Status == "valid", nil 1083 } 1084 1085 // ProjectCreate creates a new project on GitLab 1086 func ProjectCreate(opts *gitlab.CreateProjectOptions) (*gitlab.Project, error) { 1087 p, _, err := lab.Projects.CreateProject(opts) 1088 if err != nil { 1089 return nil, err 1090 } 1091 return p, nil 1092 } 1093 1094 // ProjectDelete creates a new project on GitLab 1095 func ProjectDelete(projID interface{}) error { 1096 _, err := lab.Projects.DeleteProject(projID) 1097 if err != nil { 1098 return err 1099 } 1100 return nil 1101 } 1102 1103 // ProjectList gets a list of projects on GitLab 1104 func ProjectList(opts gitlab.ListProjectsOptions, n int) ([]*gitlab.Project, error) { 1105 if n == -1 { 1106 n = maxItemsPerPage 1107 } 1108 1109 var list []*gitlab.Project 1110 for len(list) < n { 1111 opts.PerPage = n - len(list) 1112 projects, resp, err := lab.Projects.ListProjects(&opts) 1113 if err != nil { 1114 return nil, err 1115 } 1116 list = append(list, projects...) 1117 1118 var ok bool 1119 if opts.Page, ok = hasNextPage(resp); !ok { 1120 break 1121 } 1122 } 1123 1124 return list, nil 1125 } 1126 1127 // JobStruct maps the project ID to which a certain job belongs to. 1128 // It's needed due to multi-projects pipeline, which allows jobs from 1129 // different projects be triggered by the current project. 1130 // CIJob() is currently the function handling the mapping. 1131 type JobStruct struct { 1132 Job *gitlab.Job 1133 // A project ID can either be a string or an integer 1134 ProjectID interface{} 1135 } 1136 type jobSorter struct{ Jobs []JobStruct } 1137 1138 func (s jobSorter) Len() int { return len(s.Jobs) } 1139 func (s jobSorter) Swap(i, j int) { s.Jobs[i], s.Jobs[j] = s.Jobs[j], s.Jobs[i] } 1140 func (s jobSorter) Less(i, j int) bool { 1141 return time.Time(*s.Jobs[i].Job.CreatedAt).Before(time.Time(*s.Jobs[j].Job.CreatedAt)) 1142 } 1143 1144 // GroupSearch searches for a namespace on GitLab 1145 func GroupSearch(query string) (*gitlab.Group, error) { 1146 query = strings.TrimSpace(query) 1147 if query == "" { 1148 return nil, errors.New("invalid group query") 1149 } 1150 1151 groups := strings.Split(query, "/") 1152 list, _, err := lab.Groups.SearchGroup(groups[len(groups)-1]) 1153 if err != nil { 1154 return nil, err 1155 } 1156 if len(list) == 0 { 1157 return nil, fmt.Errorf("group '%s' not found", query) 1158 } 1159 if len(list) == 1 { 1160 return list[0], nil 1161 } 1162 1163 for _, group := range list { 1164 fullName := strings.TrimSpace(group.FullName) 1165 if group.FullPath == query || fullName == query { 1166 return group, nil 1167 } 1168 } 1169 1170 msg := fmt.Sprintf("found multiple groups with ambiguous name:\n") 1171 for _, group := range list { 1172 msg += fmt.Sprintf("\t%s\n", group.FullPath) 1173 } 1174 msg += fmt.Sprintf("use one of the above path options\n") 1175 1176 return nil, errors.New(msg) 1177 } 1178 1179 // CIJobs returns a list of jobs in the pipeline with given id. 1180 // This function by default doesn't follow bridge jobs. 1181 // The jobs are returned sorted by their CreatedAt time 1182 func CIJobs(projID interface{}, id int, followBridge bool, bridgeName string) ([]JobStruct, error) { 1183 opts := &gitlab.ListJobsOptions{ 1184 ListOptions: gitlab.ListOptions{ 1185 PerPage: maxItemsPerPage, 1186 }, 1187 } 1188 1189 // First we get the jobs with direct relation to the actual project 1190 list := make([]JobStruct, 0) 1191 var ok bool 1192 1193 for { 1194 jobs, resp, err := lab.Jobs.ListPipelineJobs(projID, id, opts) 1195 if err != nil { 1196 return nil, err 1197 } 1198 1199 for _, job := range jobs { 1200 list = append(list, JobStruct{job, projID}) 1201 } 1202 1203 if opts.Page, ok = hasNextPage(resp); !ok { 1204 break 1205 } 1206 } 1207 1208 // It's also possible the pipelines are bridges to other project's 1209 // pipelines (multi-project pipeline). 1210 // Reference: 1211 // https://docs.gitlab.com/ee/ci/multi_project_pipelines.html 1212 if followBridge { 1213 // A project can have multiple bridge jobs 1214 bridgeList := make([]*gitlab.Bridge, 0) 1215 for { 1216 bridges, resp, err := lab.Jobs.ListPipelineBridges(projID, id, opts) 1217 if err != nil { 1218 return nil, err 1219 } 1220 bridgeList = append(bridgeList, bridges...) 1221 1222 if opts.Page, ok = hasNextPage(resp); !ok { 1223 break 1224 } 1225 } 1226 1227 for _, bridge := range bridgeList { 1228 if bridgeName != "" && bridge.Name != bridgeName { 1229 continue 1230 } 1231 1232 // Switch to the new project name and downstream pipeline id 1233 projID = bridge.DownstreamPipeline.ProjectID 1234 id = bridge.DownstreamPipeline.ID 1235 1236 for { 1237 // Get the list of bridged jobs and append to the original list 1238 jobs, resp, err := lab.Jobs.ListPipelineJobs(projID, id, opts) 1239 if err != nil { 1240 return nil, err 1241 } 1242 1243 for _, job := range jobs { 1244 list = append(list, JobStruct{job, projID}) 1245 } 1246 1247 if opts.Page, ok = hasNextPage(resp); !ok { 1248 break 1249 } 1250 } 1251 } 1252 } 1253 1254 // ListPipelineJobs returns jobs sorted by ID in descending order, 1255 // while we want them to be ordered chronologically 1256 sort.Sort(jobSorter{list}) 1257 1258 return list, nil 1259 } 1260 1261 // CITrace searches by name for a job and returns its trace file. The trace is 1262 // static so may only be a portion of the logs if the job is till running. If 1263 // no name is provided job is picked using the first available: 1264 // 1. Last Running Job 1265 // 2. First Pending Job 1266 // 3. Last Job in Pipeline 1267 func CITrace(projID interface{}, id int, name string, followBridge bool, bridgeName string) (io.Reader, *gitlab.Job, error) { 1268 jobs, err := CIJobs(projID, id, followBridge, bridgeName) 1269 if len(jobs) == 0 || err != nil { 1270 return nil, nil, err 1271 } 1272 var ( 1273 job *gitlab.Job 1274 lastRunning *gitlab.Job 1275 firstPending *gitlab.Job 1276 ) 1277 1278 for _, jobStruct := range jobs { 1279 // Switch to the project ID that owns the job (for a bridge case) 1280 projID = jobStruct.ProjectID 1281 j := jobStruct.Job 1282 if j.Status == "running" { 1283 lastRunning = j 1284 } 1285 if j.Status == "pending" && firstPending == nil { 1286 firstPending = j 1287 } 1288 if j.Name == name { 1289 job = j 1290 // don't break because there may be a newer version of the job 1291 } 1292 } 1293 if job == nil { 1294 job = lastRunning 1295 } 1296 if job == nil { 1297 job = firstPending 1298 } 1299 if job == nil { 1300 job = jobs[len(jobs)-1].Job 1301 } 1302 1303 r, _, err := lab.Jobs.GetTraceFile(projID, job.ID) 1304 if err != nil { 1305 return nil, job, err 1306 } 1307 1308 return r, job, err 1309 } 1310 1311 // CIArtifacts searches by name for a job and returns its artifacts archive 1312 // together with the upstream filename. If path is specified and refers to 1313 // a single file within the artifacts archive, that file is returned instead. 1314 // If no name is provided, the last job with an artifacts file is picked. 1315 func CIArtifacts(projID interface{}, id int, name, path string, followBridge bool, bridgeName string) (io.Reader, string, error) { 1316 jobs, err := CIJobs(projID, id, followBridge, bridgeName) 1317 if len(jobs) == 0 || err != nil { 1318 return nil, "", err 1319 } 1320 var ( 1321 job *gitlab.Job 1322 lastWithArtifacts *gitlab.Job 1323 ) 1324 1325 for _, jobStruct := range jobs { 1326 // Switch to the project ID that owns the job (for a bridge case) 1327 projID = jobStruct.ProjectID 1328 j := jobStruct.Job 1329 if j.ArtifactsFile.Filename != "" { 1330 lastWithArtifacts = j 1331 } 1332 if j.Name == name { 1333 job = j 1334 // don't break because there may be a newer version of the job 1335 } 1336 } 1337 if job == nil { 1338 job = lastWithArtifacts 1339 } 1340 if job == nil { 1341 return nil, "", fmt.Errorf("Could not find any jobs with artifacts") 1342 } 1343 1344 var ( 1345 r io.Reader 1346 outpath string 1347 ) 1348 1349 if job.ArtifactsFile.Filename == "" { 1350 return nil, "", fmt.Errorf("Job %d has no artifacts", job.ID) 1351 } 1352 1353 fmt.Println("Downloading artifacts...") 1354 if path != "" { 1355 r, _, err = lab.Jobs.DownloadSingleArtifactsFile(projID, job.ID, path, nil) 1356 outpath = filepath.Base(path) 1357 } else { 1358 r, _, err = lab.Jobs.GetJobArtifacts(projID, job.ID, nil) 1359 outpath = job.ArtifactsFile.Filename 1360 } 1361 1362 if err != nil { 1363 return nil, "", err 1364 } 1365 1366 return r, outpath, nil 1367 } 1368 1369 // CIPlayOrRetry runs a job either by playing it for the first time or by 1370 // retrying it based on the currently known job state 1371 func CIPlayOrRetry(projID interface{}, jobID int, status string) (*gitlab.Job, error) { 1372 switch status { 1373 case "pending", "running": 1374 return nil, nil 1375 case "manual": 1376 j, _, err := lab.Jobs.PlayJob(projID, jobID) 1377 if err != nil { 1378 return nil, err 1379 } 1380 return j, nil 1381 default: 1382 1383 j, _, err := lab.Jobs.RetryJob(projID, jobID) 1384 if err != nil { 1385 return nil, err 1386 } 1387 1388 return j, nil 1389 } 1390 } 1391 1392 // CICancel cancels a job for a given project by its ID. 1393 func CICancel(projID interface{}, jobID int) (*gitlab.Job, error) { 1394 j, _, err := lab.Jobs.CancelJob(projID, jobID) 1395 if err != nil { 1396 return nil, err 1397 } 1398 return j, nil 1399 } 1400 1401 // CICreate creates a pipeline for given ref 1402 func CICreate(projID interface{}, opts *gitlab.CreatePipelineOptions) (*gitlab.Pipeline, error) { 1403 p, _, err := lab.Pipelines.CreatePipeline(projID, opts) 1404 if err != nil { 1405 return nil, err 1406 } 1407 return p, nil 1408 } 1409 1410 // CITrigger triggers a pipeline for given ref 1411 func CITrigger(projID interface{}, opts gitlab.RunPipelineTriggerOptions) (*gitlab.Pipeline, error) { 1412 p, _, err := lab.PipelineTriggers.RunPipelineTrigger(projID, &opts) 1413 if err != nil { 1414 return nil, err 1415 } 1416 return p, nil 1417 } 1418 1419 // UserIDFromUsername returns the associated Users ID in GitLab. This is useful 1420 // for API calls that allow you to reference a user, but only by ID. 1421 func UserIDFromUsername(username string) (int, error) { 1422 us, _, err := lab.Users.ListUsers(&gitlab.ListUsersOptions{ 1423 Username: gitlab.String(username), 1424 }) 1425 if err != nil || len(us) == 0 { 1426 return -1, err 1427 } 1428 return us[0].ID, nil 1429 } 1430 1431 // UserIDFromEmail returns the associated Users ID in GitLab. This is useful 1432 // for API calls that allow you to reference a user, but only by ID. 1433 func UserIDFromEmail(email string) (int, error) { 1434 us, _, err := lab.Users.ListUsers(&gitlab.ListUsersOptions{ 1435 Search: gitlab.String(email), 1436 }) 1437 if err != nil || len(us) == 0 { 1438 return -1, err 1439 } 1440 return us[0].ID, nil 1441 } 1442 1443 // AddMRDiscussionNote adds a note to an existing MR discussion on GitLab 1444 func AddMRDiscussionNote(projID interface{}, mrID int, discussionID string, body string) (string, error) { 1445 opts := &gitlab.AddMergeRequestDiscussionNoteOptions{ 1446 Body: &body, 1447 } 1448 1449 note, _, err := lab.Discussions.AddMergeRequestDiscussionNote(projID, mrID, discussionID, opts) 1450 if err != nil { 1451 return "", err 1452 } 1453 1454 p, err := FindProject(projID) 1455 if err != nil { 1456 return "", err 1457 } 1458 return fmt.Sprintf("%s/merge_requests/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 1459 } 1460 1461 // AddIssueDiscussionNote adds a note to an existing issue discussion on GitLab 1462 func AddIssueDiscussionNote(projID interface{}, issueID int, discussionID string, body string) (string, error) { 1463 opts := &gitlab.AddIssueDiscussionNoteOptions{ 1464 Body: &body, 1465 } 1466 1467 note, _, err := lab.Discussions.AddIssueDiscussionNote(projID, issueID, discussionID, opts) 1468 if err != nil { 1469 return "", err 1470 } 1471 1472 p, err := FindProject(projID) 1473 if err != nil { 1474 return "", err 1475 } 1476 return fmt.Sprintf("%s/issues/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 1477 } 1478 1479 // UpdateIssueDiscussionNote updates a specific discussion or note in the 1480 // specified issue number 1481 func UpdateIssueDiscussionNote(projID interface{}, issueID int, discussionID string, noteID int, body string) (string, error) { 1482 opts := &gitlab.UpdateIssueDiscussionNoteOptions{ 1483 Body: &body, 1484 } 1485 1486 note, _, err := lab.Discussions.UpdateIssueDiscussionNote(projID, issueID, discussionID, noteID, opts) 1487 if err != nil { 1488 return "", err 1489 } 1490 1491 p, err := FindProject(projID) 1492 if err != nil { 1493 return "", err 1494 } 1495 return fmt.Sprintf("%s/issues/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 1496 } 1497 1498 // UpdateMRDiscussionNote updates a specific discussion or note in the 1499 // specified MR ID. 1500 func UpdateMRDiscussionNote(projID interface{}, mrID int, discussionID string, noteID int, body string) (string, error) { 1501 opts := &gitlab.UpdateMergeRequestDiscussionNoteOptions{ 1502 Body: &body, 1503 } 1504 1505 note, _, err := lab.Discussions.UpdateMergeRequestDiscussionNote(projID, mrID, discussionID, noteID, opts) 1506 if err != nil { 1507 return "", err 1508 } 1509 1510 p, err := FindProject(projID) 1511 if err != nil { 1512 return "", err 1513 } 1514 return fmt.Sprintf("%s/merge_requests/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 1515 } 1516 1517 // ListMRsClosingIssue returns a list of MR IDs that has relation to an issue 1518 // being closed 1519 func ListMRsClosingIssue(projID interface{}, id int) ([]int, error) { 1520 var retArray []int 1521 1522 mrs, _, err := lab.Issues.ListMergeRequestsClosingIssue(projID, id, nil, nil) 1523 if err != nil { 1524 return retArray, err 1525 } 1526 1527 for _, mr := range mrs { 1528 retArray = append(retArray, mr.IID) 1529 } 1530 1531 return retArray, nil 1532 } 1533 1534 // ListMRsRelatedToIssue return a list of MR IDs that has any relations to a 1535 // certain issue 1536 func ListMRsRelatedToIssue(projID interface{}, id int) ([]int, error) { 1537 var retArray []int 1538 1539 mrs, _, err := lab.Issues.ListMergeRequestsRelatedToIssue(projID, id, nil, nil) 1540 if err != nil { 1541 return retArray, err 1542 } 1543 1544 for _, mr := range mrs { 1545 retArray = append(retArray, mr.IID) 1546 } 1547 1548 return retArray, nil 1549 } 1550 1551 // ListIssuesClosedOnMerge retuns a list of issue numbers that were closed by 1552 // an MR being merged 1553 func ListIssuesClosedOnMerge(projID interface{}, id int) ([]int, error) { 1554 var retArray []int 1555 1556 issues, _, err := lab.MergeRequests.GetIssuesClosedOnMerge(projID, id, nil, nil) 1557 if err != nil { 1558 return retArray, err 1559 } 1560 1561 for _, issue := range issues { 1562 retArray = append(retArray, issue.IID) 1563 } 1564 1565 return retArray, nil 1566 } 1567 1568 // MoveIssue moves one issue from one project to another 1569 func MoveIssue(projID interface{}, id int, destProjID interface{}) (string, error) { 1570 destProject, err := FindProject(destProjID) 1571 if err != nil { 1572 return "", err 1573 } 1574 1575 opts := &gitlab.MoveIssueOptions{ 1576 ToProjectID: &destProject.ID, 1577 } 1578 1579 issue, _, err := lab.Issues.MoveIssue(projID, id, opts) 1580 if err != nil { 1581 return "", err 1582 } 1583 1584 return fmt.Sprintf("%s/issues/%d", destProject.WebURL, issue.IID), nil 1585 } 1586 1587 // GetMRApprovalsConfiguration returns the current MR approval rule 1588 func GetMRApprovalsConfiguration(projID interface{}, id int) (*gitlab.MergeRequestApprovals, error) { 1589 configuration, _, err := lab.MergeRequestApprovals.GetConfiguration(projID, id) 1590 if err != nil { 1591 return nil, err 1592 } 1593 1594 return configuration, err 1595 } 1596 1597 // ResolveMRDiscussion resolves a discussion (blocking thread) based on its ID 1598 func ResolveMRDiscussion(projID interface{}, mrID int, discussionID string, noteID int) (string, error) { 1599 opts := &gitlab.ResolveMergeRequestDiscussionOptions{ 1600 Resolved: gitlab.Bool(true), 1601 } 1602 1603 discussion, _, err := lab.Discussions.ResolveMergeRequestDiscussion(projID, mrID, discussionID, opts) 1604 if err != nil { 1605 return discussion.ID, err 1606 } 1607 1608 p, err := FindProject(projID) 1609 if err != nil { 1610 return "", err 1611 } 1612 return fmt.Sprintf("Resolved %s/merge_requests/%d#note_%d", p.WebURL, mrID, noteID), nil 1613 } 1614 1615 // TodoList retuns a list of *gitlab.Todo refering to user's Todo list 1616 func TodoList(opts gitlab.ListTodosOptions, n int) ([]*gitlab.Todo, error) { 1617 if n == -1 { 1618 n = maxItemsPerPage 1619 } 1620 1621 var list []*gitlab.Todo 1622 for len(list) < n { 1623 opts.PerPage = n - len(list) 1624 todos, resp, err := lab.Todos.ListTodos(&opts) 1625 if err != nil { 1626 return nil, err 1627 } 1628 list = append(list, todos...) 1629 1630 var ok bool 1631 if opts.Page, ok = hasNextPage(resp); !ok { 1632 break 1633 } 1634 } 1635 1636 return list, nil 1637 } 1638 1639 // TodoMarkDone marks a specific Todo as done 1640 func TodoMarkDone(id int) error { 1641 _, err := lab.Todos.MarkTodoAsDone(id) 1642 if err != nil { 1643 return err 1644 } 1645 return nil 1646 } 1647 1648 // TodoMarkAllDone marks all Todos items as done 1649 func TodoMarkAllDone() error { 1650 _, err := lab.Todos.MarkAllTodosAsDone() 1651 if err != nil { 1652 return err 1653 } 1654 return nil 1655 } 1656 1657 // TodoMRCreate create a Todo item for an specific MR 1658 func TodoMRCreate(projID interface{}, id int) (int, error) { 1659 todo, resp, err := lab.MergeRequests.CreateTodo(projID, id) 1660 if err != nil { 1661 if resp.StatusCode == http.StatusNotModified { 1662 return 0, ErrNotModified 1663 } 1664 return 0, err 1665 } 1666 return todo.ID, nil 1667 } 1668 1669 // TodoIssueCreate create a Todo item for an specific Issue 1670 func TodoIssueCreate(projID interface{}, id int) (int, error) { 1671 todo, resp, err := lab.Issues.CreateTodo(projID, id) 1672 if err != nil { 1673 if resp.StatusCode == http.StatusNotModified { 1674 return 0, ErrNotModified 1675 } 1676 return 0, err 1677 } 1678 return todo.ID, nil 1679 } 1680 1681 func GetCommitDiff(projID interface{}, sha string) ([]*gitlab.Diff, error) { 1682 var diffs []*gitlab.Diff 1683 opt := &gitlab.GetCommitDiffOptions{ 1684 PerPage: maxItemsPerPage, 1685 } 1686 1687 for { 1688 ds, resp, err := lab.Commits.GetCommitDiff(projID, sha, opt) 1689 if err != nil { 1690 if resp.StatusCode == 404 { 1691 log.Fatalf("Cannot find diff for commit %s. Verify the commit ID or add more characters to the commit ID.", sha) 1692 } 1693 return nil, err 1694 } 1695 1696 diffs = append(diffs, ds...) 1697 1698 // if we've seen all the pages, then we can break here 1699 // otherwise, update the page number to get the next page. 1700 var ok bool 1701 if opt.Page, ok = hasNextPage(resp); !ok { 1702 break 1703 } 1704 } 1705 1706 return diffs, nil 1707 } 1708 1709 func IssueDeleteNote(projID interface{}, issue int, discussion string, note int) error { 1710 1711 if discussion == "" { 1712 _, err := lab.Notes.DeleteIssueNote(projID, issue, note) 1713 if err != nil { 1714 return err 1715 } 1716 return nil 1717 } 1718 1719 _, err := lab.Discussions.DeleteIssueDiscussionNote(projID, issue, discussion, note) 1720 if err != nil { 1721 return err 1722 } 1723 return nil 1724 } 1725 1726 func MRDeleteNote(projID interface{}, mr int, discussion string, note int) error { 1727 1728 if discussion == "" { 1729 _, err := lab.Notes.DeleteMergeRequestNote(projID, mr, note) 1730 if err != nil { 1731 return err 1732 } 1733 return nil 1734 } 1735 1736 _, err := lab.Discussions.DeleteMergeRequestDiscussionNote(projID, mr, discussion, note) 1737 if err != nil { 1738 return err 1739 } 1740 return nil 1741 } 1742 1743 func CreateCommitComment(projID interface{}, sha string, newFile string, oldFile string, line int, linetype string, comment string) (string, error) { 1744 // Ideally want to use lab.Commits.PostCommitComment, however, 1745 // that API only support comments on linetype=new. 1746 // 1747 // https://gitlab.com/gitlab-org/gitlab/-/issues/335337 1748 commitInfo, err := GetCommit(projID, sha) 1749 if err != nil { 1750 fmt.Printf("Could not get diff for commit %s.\n", sha) 1751 return "", err 1752 } 1753 1754 if len(commitInfo.ParentIDs) > 1 { 1755 log.Fatalf("Commit %s has mulitple parents. This interface cannot be used for comments.\n", sha) 1756 return "", err 1757 } 1758 1759 position := gitlab.NotePosition{ 1760 BaseSHA: commitInfo.ParentIDs[0], 1761 StartSHA: commitInfo.ParentIDs[0], 1762 HeadSHA: sha, 1763 PositionType: "text", 1764 } 1765 1766 switch linetype { 1767 case "new": 1768 position.NewPath = newFile 1769 position.NewLine = line 1770 case "old": 1771 position.OldPath = oldFile 1772 position.OldLine = line 1773 case "context": 1774 position.NewPath = newFile 1775 position.NewLine = line 1776 position.OldPath = oldFile 1777 position.OldLine = line 1778 } 1779 1780 opt := &gitlab.CreateCommitDiscussionOptions{ 1781 Body: &comment, 1782 Position: &position, 1783 } 1784 1785 commitDiscussion, _, err := lab.Discussions.CreateCommitDiscussion(projID, sha, opt) 1786 if err != nil { 1787 return "", err 1788 } 1789 1790 return fmt.Sprintf("%s#note_%d", commitInfo.WebURL, commitDiscussion.Notes[0].ID), nil 1791 } 1792 1793 func CreateMergeRequestCommitDiscussion(projID interface{}, id int, sha string, newFile string, oldFile string, line int, linetype string, comment string) (string, error) { 1794 commitInfo, err := GetCommit(projID, sha) 1795 if err != nil { 1796 fmt.Printf("Could not get diff for commit %s.\n", sha) 1797 return "", err 1798 } 1799 1800 if len(commitInfo.ParentIDs) > 1 { 1801 log.Fatalf("Commit %s has mulitple parents. This interface cannot be used for comments.\n", sha) 1802 return "", err 1803 } 1804 1805 position := gitlab.NotePosition{ 1806 NewPath: newFile, 1807 OldPath: oldFile, 1808 BaseSHA: commitInfo.ParentIDs[0], 1809 StartSHA: commitInfo.ParentIDs[0], 1810 HeadSHA: sha, 1811 PositionType: "text", 1812 } 1813 1814 switch linetype { 1815 case "new": 1816 position.NewLine = line 1817 case "old": 1818 position.OldLine = line 1819 case "context": 1820 position.NewLine = line 1821 position.OldLine = line 1822 } 1823 1824 opt := &gitlab.CreateMergeRequestDiscussionOptions{ 1825 Body: &comment, 1826 Position: &position, 1827 CommitID: &sha, 1828 } 1829 1830 discussion, _, err := lab.Discussions.CreateMergeRequestDiscussion(projID, id, opt) 1831 if err != nil { 1832 return "", err 1833 } 1834 1835 note := discussion.Notes[0] 1836 p, err := FindProject(projID) 1837 if err != nil { 1838 return "", err 1839 } 1840 return fmt.Sprintf("%s/merge_requests/%d#note_%d", p.WebURL, note.NoteableIID, note.ID), nil 1841 } 1842 1843 // hasNextPage get the next page number in case the API response has more 1844 // than one. It also uses only the "X-Page" and "X-Next-Page" HTTP headers, 1845 // since in some cases the API response may come without the HTTP 1846 // X-Total(-Page) header. Reference: 1847 // https://docs.gitlab.com/ee/user/gitlab_com/index.html#pagination-response-headers 1848 func hasNextPage(resp *gitlab.Response) (int, bool) { 1849 if resp.CurrentPage >= resp.NextPage { 1850 return 0, false 1851 } 1852 return resp.NextPage, true 1853 }