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  }