mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-30 12:58:40 +00:00
724 lines
26 KiB
Go
724 lines
26 KiB
Go
// Copyright 2017 HootSuite Media Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the License);
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an AS IS BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
// Modified hereafter by contributors to runatlantis/atlantis.
|
|
|
|
package vcs
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-version"
|
|
"github.com/jpillora/backoff"
|
|
"github.com/pkg/errors"
|
|
"github.com/runatlantis/atlantis/server/events/command"
|
|
"github.com/runatlantis/atlantis/server/events/models"
|
|
"github.com/runatlantis/atlantis/server/events/vcs/common"
|
|
"github.com/runatlantis/atlantis/server/logging"
|
|
gitlab "gitlab.com/gitlab-org/api/client-go"
|
|
)
|
|
|
|
// gitlabMaxCommentLength is the maximum number of chars allowed by Gitlab in a
|
|
// single comment, reduced by 100 to allow comments to be hidden with a summary header
|
|
// and footer.
|
|
const gitlabMaxCommentLength = 1000000 - 100
|
|
|
|
type GitlabClient struct {
|
|
Client *gitlab.Client
|
|
// Version is set to the server version.
|
|
Version *version.Version
|
|
// All GitLab groups configured in allowlists and policies
|
|
ConfiguredGroups []string
|
|
// PollingInterval is the time between successive polls, where applicable.
|
|
PollingInterval time.Duration
|
|
// PollingInterval is the total duration for which to poll, where applicable.
|
|
PollingTimeout time.Duration
|
|
}
|
|
|
|
// commonMarkSupported is a version constraint that is true when this version of
|
|
// GitLab supports CommonMark, a markdown specification.
|
|
// See https://about.gitlab.com/2018/07/22/gitlab-11-1-released/
|
|
var commonMarkSupported = MustConstraint(">=11.1")
|
|
|
|
// gitlabClientUnderTest is true if we're running under go test.
|
|
var gitlabClientUnderTest = false
|
|
|
|
// NewGitlabClient returns a valid GitLab client.
|
|
func NewGitlabClient(hostname string, token string, configuredGroups []string, logger logging.SimpleLogging) (*GitlabClient, error) {
|
|
logger.Debug("Creating new GitLab client for %s", hostname)
|
|
client := &GitlabClient{
|
|
ConfiguredGroups: configuredGroups,
|
|
PollingInterval: time.Second,
|
|
PollingTimeout: time.Second * 30,
|
|
}
|
|
|
|
// Create the client differently depending on the base URL.
|
|
if hostname == "gitlab.com" {
|
|
glClient, err := gitlab.NewClient(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.Client = glClient
|
|
} else {
|
|
// We assume the url will be over HTTPS if the user doesn't specify a scheme.
|
|
absoluteURL := hostname
|
|
if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") {
|
|
absoluteURL = "https://" + absoluteURL
|
|
}
|
|
|
|
url, err := url.Parse(absoluteURL)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "parsing URL %q", absoluteURL)
|
|
}
|
|
|
|
// Warn if this hostname isn't resolvable. The GitLab client
|
|
// doesn't give good error messages in this case.
|
|
ips, err := net.LookupIP(url.Hostname())
|
|
if err != nil {
|
|
logger.Warn("unable to resolve %q: %s", url.Hostname(), err)
|
|
} else if len(ips) == 0 {
|
|
logger.Warn("found no IPs while resolving %q", url.Hostname())
|
|
}
|
|
|
|
// Now we're ready to construct the client.
|
|
absoluteURL = strings.TrimSuffix(absoluteURL, "/")
|
|
apiURL := fmt.Sprintf("%s/api/v4/", absoluteURL)
|
|
glClient, err := gitlab.NewClient(token, gitlab.WithBaseURL(apiURL))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
client.Client = glClient
|
|
}
|
|
|
|
// Determine which version of GitLab is running.
|
|
if !gitlabClientUnderTest {
|
|
var err error
|
|
client.Version, err = client.GetVersion(logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger.Info("GitLab host '%s' is running version %s", client.Client.BaseURL().Host, client.Version.String())
|
|
}
|
|
|
|
return client, nil
|
|
}
|
|
|
|
// GetModifiedFiles returns the names of files that were modified in the merge request
|
|
// relative to the repo root, e.g. parent/child/file.txt.
|
|
func (g *GitlabClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
|
|
logger.Debug("Getting modified files for GitLab merge request %d", pull.Num)
|
|
const maxPerPage = 100
|
|
var files []string
|
|
nextPage := 1
|
|
// Constructing the api url by hand so we can do pagination.
|
|
apiURL := fmt.Sprintf("projects/%s/merge_requests/%d/changes", url.QueryEscape(repo.FullName), pull.Num)
|
|
for {
|
|
opts := gitlab.ListOptions{
|
|
Page: nextPage,
|
|
PerPage: maxPerPage,
|
|
}
|
|
req, err := g.Client.NewRequest("GET", apiURL, opts, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp := new(gitlab.Response)
|
|
mr := new(gitlab.MergeRequest)
|
|
pollingStart := time.Now()
|
|
for {
|
|
resp, err = g.Client.Do(req, mr)
|
|
if resp != nil {
|
|
logger.Debug("GET %s returned: %d", apiURL, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if mr.ChangesCount != "" {
|
|
break
|
|
}
|
|
if time.Since(pollingStart) > g.PollingTimeout {
|
|
return nil, errors.Errorf("giving up polling %q after %s", apiURL, g.PollingTimeout.String())
|
|
}
|
|
time.Sleep(g.PollingInterval)
|
|
}
|
|
|
|
for _, f := range mr.Changes {
|
|
files = append(files, f.NewPath)
|
|
|
|
// If the file was renamed, we'll want to run plan in the directory
|
|
// it was moved from as well.
|
|
if f.RenamedFile {
|
|
files = append(files, f.OldPath)
|
|
}
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
nextPage = resp.NextPage
|
|
}
|
|
|
|
return files, nil
|
|
}
|
|
|
|
// CreateComment creates a comment on the merge request.
|
|
func (g *GitlabClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, _ string) error {
|
|
logger.Debug("Creating comment on GitLab merge request %d", pullNum)
|
|
sepEnd := "\n```\n</details>" +
|
|
"\n<br>\n\n**Warning**: Output length greater than max comment size. Continued in next comment."
|
|
sepStart := "Continued from previous comment.\n<details><summary>Show Output</summary>\n\n" +
|
|
"```diff\n"
|
|
comments := common.SplitComment(comment, gitlabMaxCommentLength, sepEnd, sepStart, 0, "")
|
|
for _, c := range comments {
|
|
_, resp, err := g.Client.Notes.CreateMergeRequestNote(repo.FullName, pullNum, &gitlab.CreateMergeRequestNoteOptions{Body: gitlab.Ptr(c)})
|
|
if resp != nil {
|
|
logger.Debug("POST /projects/%s/merge_requests/%d/notes returned: %d", repo.FullName, pullNum, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReactToComment adds a reaction to a comment.
|
|
func (g *GitlabClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error {
|
|
logger.Debug("Adding reaction '%s' to comment %d on GitLab merge request %d", reaction, commentID, pullNum)
|
|
_, resp, err := g.Client.AwardEmoji.CreateMergeRequestAwardEmojiOnNote(repo.FullName, pullNum, int(commentID), &gitlab.CreateAwardEmojiOptions{Name: reaction})
|
|
if resp != nil {
|
|
logger.Debug("POST /projects/%s/merge_requests/%d/notes/%d/award_emoji returned: %d", repo.FullName, pullNum, commentID, resp.StatusCode)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (g *GitlabClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error {
|
|
logger.Debug("Hiding previous command comments on GitLab merge request %d", pullNum)
|
|
var allComments []*gitlab.Note
|
|
|
|
nextPage := 0
|
|
for {
|
|
logger.Debug("/projects/%v/merge_requests/%d/notes", repo.FullName, pullNum)
|
|
comments, resp, err := g.Client.Notes.ListMergeRequestNotes(repo.FullName, pullNum,
|
|
&gitlab.ListMergeRequestNotesOptions{
|
|
Sort: gitlab.Ptr("asc"),
|
|
OrderBy: gitlab.Ptr("created_at"),
|
|
ListOptions: gitlab.ListOptions{Page: nextPage},
|
|
})
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/merge_requests/%d/notes returned: %d", repo.FullName, pullNum, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return errors.Wrap(err, "listing comments")
|
|
}
|
|
allComments = append(allComments, comments...)
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
nextPage = resp.NextPage
|
|
}
|
|
|
|
currentUser, _, err := g.Client.Users.CurrentUser()
|
|
if err != nil {
|
|
return errors.Wrap(err, "error getting currentuser")
|
|
}
|
|
|
|
summaryHeader := fmt.Sprintf("<!--- +-Superseded Command-+ ---><details><summary>Superseded Atlantis %s</summary>", command)
|
|
summaryFooter := "</details>"
|
|
lineFeed := "\n"
|
|
|
|
for _, comment := range allComments {
|
|
// Only process non-system comments authored by the Atlantis user
|
|
if comment.System || (comment.Author.Username != "" && !strings.EqualFold(comment.Author.Username, currentUser.Username)) {
|
|
continue
|
|
}
|
|
|
|
body := strings.Split(comment.Body, "\n")
|
|
if len(body) == 0 {
|
|
continue
|
|
}
|
|
firstLine := strings.ToLower(body[0])
|
|
// Skip processing comments that don't contain the command or contain the summary header in the first line
|
|
if !strings.Contains(firstLine, strings.ToLower(command)) || firstLine == strings.ToLower(summaryHeader) {
|
|
continue
|
|
}
|
|
|
|
// If dir was specified, skip processing comments that don't contain the dir in the first line
|
|
if dir != "" && !strings.Contains(firstLine, strings.ToLower(dir)) {
|
|
continue
|
|
}
|
|
|
|
logger.Debug("Updating merge request note: Repo: '%s', MR: '%d', comment ID: '%d'", repo.FullName, pullNum, comment.ID)
|
|
supersededComment := summaryHeader + lineFeed + comment.Body + lineFeed + summaryFooter + lineFeed
|
|
|
|
_, resp, err := g.Client.Notes.UpdateMergeRequestNote(repo.FullName, pullNum, comment.ID, &gitlab.UpdateMergeRequestNoteOptions{Body: &supersededComment})
|
|
if resp != nil {
|
|
logger.Debug("PUT /projects/%s/merge_requests/%d/notes/%d returned: %d", repo.FullName, pullNum, comment.ID, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return errors.Wrapf(err, "updating comment %d", comment.ID)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PullIsApproved returns true if the merge request was approved.
|
|
func (g *GitlabClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) {
|
|
logger.Debug("Checking if GitLab merge request %d is approved", pull.Num)
|
|
approvals, resp, err := g.Client.MergeRequests.GetMergeRequestApprovals(repo.FullName, pull.Num)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/merge_requests/%d/approvals returned: %d", repo.FullName, pull.Num, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return approvalStatus, err
|
|
}
|
|
if approvals.ApprovalsLeft > 0 {
|
|
return approvalStatus, nil
|
|
}
|
|
return models.ApprovalStatus{
|
|
IsApproved: true,
|
|
}, nil
|
|
}
|
|
|
|
// PullIsMergeable returns true if the merge request can be merged.
|
|
// In GitLab, there isn't a single field that tells us if the pull request is
|
|
// mergeable so for now we check the merge_status and approvals_before_merge
|
|
// fields.
|
|
// In order to check if the repo required these, we'd need to make another API
|
|
// call to get the repo settings.
|
|
// It's also possible that GitLab implements their own "mergeable" field in
|
|
// their API in the future.
|
|
// See:
|
|
// - https://gitlab.com/gitlab-org/gitlab-ee/issues/3169
|
|
// - https://gitlab.com/gitlab-org/gitlab-ce/issues/42344
|
|
func (g *GitlabClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, vcsstatusname string, _ []string) (bool, error) {
|
|
logger.Debug("Checking if GitLab merge request %d is mergeable", pull.Num)
|
|
mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repo.FullName, pull.Num, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repo.FullName, pull.Num, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Prevent nil pointer error when mr.HeadPipeline is empty
|
|
// See: https://github.com/runatlantis/atlantis/issues/1852
|
|
commit := pull.HeadCommit
|
|
isPipelineSkipped := false
|
|
if mr.HeadPipeline != nil {
|
|
commit = mr.HeadPipeline.SHA
|
|
isPipelineSkipped = mr.HeadPipeline.Status == "skipped"
|
|
}
|
|
|
|
// Get project configuration
|
|
project, resp, err := g.Client.Projects.GetProject(mr.ProjectID, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%d returned: %d", mr.ProjectID, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
// Get Commit Statuses
|
|
statuses, _, err := g.Client.Commits.GetCommitStatuses(mr.ProjectID, commit, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%d/commits/%s/statuses returned: %d", mr.ProjectID, commit, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, status := range statuses {
|
|
// Ignore any commit statuses with 'atlantis/apply' as prefix
|
|
if strings.HasPrefix(status.Name, fmt.Sprintf("%s/%s", vcsstatusname, command.Apply.String())) {
|
|
continue
|
|
}
|
|
if !status.AllowFailure && project.OnlyAllowMergeIfPipelineSucceeds && status.Status != "success" {
|
|
return false, nil
|
|
}
|
|
}
|
|
|
|
allowSkippedPipeline := project.AllowMergeOnSkippedPipeline && isPipelineSkipped
|
|
|
|
supportsDetailedMergeStatus, err := g.SupportsDetailedMergeStatus(logger)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if supportsDetailedMergeStatus {
|
|
logger.Debug("Detailed merge status: '%s'", mr.DetailedMergeStatus)
|
|
} else {
|
|
logger.Debug("Merge status: '%s'", mr.MergeStatus) //nolint:staticcheck // Need to reference deprecated field for backwards compatibility
|
|
}
|
|
|
|
if ((supportsDetailedMergeStatus &&
|
|
(mr.DetailedMergeStatus == "mergeable" ||
|
|
mr.DetailedMergeStatus == "ci_still_running" ||
|
|
mr.DetailedMergeStatus == "ci_must_pass" ||
|
|
mr.DetailedMergeStatus == "need_rebase")) ||
|
|
(!supportsDetailedMergeStatus &&
|
|
mr.MergeStatus == "can_be_merged")) && //nolint:staticcheck // Need to reference deprecated field for backwards compatibility
|
|
mr.ApprovalsBeforeMerge <= 0 &&
|
|
mr.BlockingDiscussionsResolved &&
|
|
!mr.WorkInProgress &&
|
|
(allowSkippedPipeline || !isPipelineSkipped) {
|
|
|
|
logger.Debug("Merge request is mergeable")
|
|
return true, nil
|
|
}
|
|
logger.Debug("Merge request is not mergeable")
|
|
return false, nil
|
|
}
|
|
|
|
func (g *GitlabClient) SupportsDetailedMergeStatus(logger logging.SimpleLogging) (bool, error) {
|
|
logger.Debug("Checking if GitLab supports detailed merge status")
|
|
v, err := g.GetVersion(logger)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
cons, err := version.NewConstraint(">= 15.6")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return cons.Check(v), nil
|
|
}
|
|
|
|
// UpdateStatus updates the build status of a commit.
|
|
func (g *GitlabClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error {
|
|
gitlabState := gitlab.Pending
|
|
switch state {
|
|
case models.PendingCommitStatus:
|
|
gitlabState = gitlab.Running
|
|
case models.FailedCommitStatus:
|
|
gitlabState = gitlab.Failed
|
|
case models.SuccessCommitStatus:
|
|
gitlabState = gitlab.Success
|
|
}
|
|
|
|
logger.Info("Updating GitLab commit status for '%s' to '%s'", src, gitlabState)
|
|
|
|
setCommitStatusOptions := &gitlab.SetCommitStatusOptions{
|
|
State: gitlabState,
|
|
Context: gitlab.Ptr(src),
|
|
Description: gitlab.Ptr(description),
|
|
TargetURL: &url,
|
|
}
|
|
|
|
retries := 1
|
|
delay := 2 * time.Second
|
|
var commit *gitlab.Commit
|
|
var resp *gitlab.Response
|
|
var err error
|
|
|
|
// Try a couple of times to get the pipeline ID for the commit
|
|
for i := 0; i <= retries; i++ {
|
|
commit, resp, err = g.Client.Commits.GetCommit(repo.FullName, pull.HeadCommit, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/repository/commits/%d: %d", pull.BaseRepo.ID(), pull.HeadCommit, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if commit.LastPipeline != nil {
|
|
logger.Info("Pipeline found for commit %s, setting pipeline ID to %d", pull.HeadCommit, commit.LastPipeline.ID)
|
|
// Set the pipeline ID to the last pipeline that ran for the commit
|
|
setCommitStatusOptions.PipelineID = gitlab.Ptr(commit.LastPipeline.ID)
|
|
break
|
|
}
|
|
if i != retries {
|
|
logger.Info("No pipeline found for commit %s, retrying in %s", pull.HeadCommit, delay)
|
|
time.Sleep(delay)
|
|
} else {
|
|
// If we've exhausted all retries, set the Ref to the branch name
|
|
logger.Info("No pipeline found for commit %s, setting Ref to %s", pull.HeadCommit, pull.HeadBranch)
|
|
setCommitStatusOptions.Ref = gitlab.Ptr(pull.HeadBranch)
|
|
}
|
|
}
|
|
|
|
var (
|
|
maxAttempts = 10
|
|
retryer = &backoff.Backoff{
|
|
Jitter: true,
|
|
Max: g.PollingInterval,
|
|
}
|
|
)
|
|
|
|
for {
|
|
attempt := int(retryer.Attempt()) + 1
|
|
logger := logger.With(
|
|
"attempt", attempt,
|
|
"max_attempts", maxAttempts,
|
|
"repo", repo.FullName,
|
|
"commit", commit.ShortID,
|
|
"state", state.String(),
|
|
)
|
|
|
|
_, resp, err := g.Client.Commits.SetCommitStatus(repo.FullName, pull.HeadCommit, setCommitStatusOptions)
|
|
if err == nil {
|
|
if retryer.Attempt() > 0 {
|
|
logger.Info("GitLab returned HTTP [200 OK] after updating commit status")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
if attempt == maxAttempts {
|
|
return errors.Wrap(err, fmt.Sprintf("failed to update commit status for '%s' @ '%s' to '%s' after %d attempts", repo.FullName, pull.HeadCommit, src, attempt))
|
|
}
|
|
|
|
if resp != nil {
|
|
logger.Debug("POST /projects/%s/statuses/%s returned: %d", repo.FullName, pull.HeadCommit, resp.StatusCode)
|
|
|
|
// GitLab returns a `409 Conflict` status when the commit pipeline status is being changed/locked by another request,
|
|
// which is likely to happen if you use [`--parallel-pool-size > 1`] and [`parallel-plan|apply`].
|
|
//
|
|
// The likelihood of this happening is increased when the number of parallel apply jobs is increased.
|
|
//
|
|
// Returning the [err] without retrying will permanently leave the GitLab commit status in a "running" state,
|
|
// which would prevent Atlantis from merging the merge request on [apply].
|
|
//
|
|
// GitLab does not allow merge requests to be merged when the pipeline status is "running."
|
|
|
|
if resp.StatusCode == http.StatusConflict {
|
|
logger.Warn("GitLab returned HTTP [409 Conflict] when updating commit status")
|
|
}
|
|
}
|
|
|
|
sleep := retryer.Duration()
|
|
|
|
logger.With("retry_in", sleep).Warn("GitLab errored when updating commit status: %w", err)
|
|
time.Sleep(sleep)
|
|
}
|
|
}
|
|
|
|
func (g *GitlabClient) GetMergeRequest(logger logging.SimpleLogging, repoFullName string, pullNum int) (*gitlab.MergeRequest, error) {
|
|
logger.Debug("Getting GitLab merge request %d", pullNum)
|
|
mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repoFullName, pullNum, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repoFullName, pullNum, resp.StatusCode)
|
|
}
|
|
return mr, err
|
|
}
|
|
|
|
func (g *GitlabClient) WaitForSuccessPipeline(logger logging.SimpleLogging, ctx context.Context, pull models.PullRequest) {
|
|
logger.Debug("Waiting for GitLab success pipeline for merge request %d", pull.Num)
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
for wait := true; wait; {
|
|
select {
|
|
case <-ctx.Done():
|
|
// validation check time out
|
|
cancel()
|
|
return // ctx.Err()
|
|
|
|
default:
|
|
mr, _ := g.GetMergeRequest(logger, pull.BaseRepo.FullName, pull.Num)
|
|
// check if pipeline has a success state to merge
|
|
if mr.HeadPipeline.Status == "success" {
|
|
return
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MergePull merges the merge request.
|
|
func (g *GitlabClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error {
|
|
logger.Debug("Merging GitLab merge request %d", pull.Num)
|
|
commitMsg := common.AutomergeCommitMsg(pull.Num)
|
|
|
|
mr, err := g.GetMergeRequest(logger, pull.BaseRepo.FullName, pull.Num)
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to merge merge request, it was not possible to retrieve the merge request")
|
|
}
|
|
project, resp, err := g.Client.Projects.GetProject(mr.ProjectID, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%d returned: %d", mr.ProjectID, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to merge merge request, it was not possible to check the project requirements")
|
|
}
|
|
|
|
if project != nil && project.OnlyAllowMergeIfPipelineSucceeds {
|
|
g.WaitForSuccessPipeline(logger, context.Background(), pull)
|
|
}
|
|
|
|
_, resp, err = g.Client.MergeRequests.AcceptMergeRequest(
|
|
pull.BaseRepo.FullName,
|
|
pull.Num,
|
|
&gitlab.AcceptMergeRequestOptions{
|
|
MergeCommitMessage: &commitMsg,
|
|
ShouldRemoveSourceBranch: &pullOptions.DeleteSourceBranchOnMerge,
|
|
})
|
|
if resp != nil {
|
|
logger.Debug("PUT /projects/%s/merge_requests/%d/merge returned: %d", pull.BaseRepo.FullName, pull.Num, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to merge merge request, it may not be in a mergeable state")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkdownPullLink specifies the string used in a pull request comment to reference another pull request.
|
|
func (g *GitlabClient) MarkdownPullLink(pull models.PullRequest) (string, error) {
|
|
return fmt.Sprintf("!%d", pull.Num), nil
|
|
}
|
|
|
|
// DiscardReviews discards all reviews on a pull request
|
|
// This is only available with a bot token and otherwise will return 401 unauthorized
|
|
// https://docs.gitlab.com/api/merge_request_approvals/#reset-approvals-of-a-merge-request
|
|
func (g *GitlabClient) DiscardReviews(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) error {
|
|
logger.Debug("Reset approvals for merge request %d", pull.Num)
|
|
resp, err := g.Client.MergeRequestApprovals.ResetApprovalsOfMergeRequest(repo.FullName, pull.Num)
|
|
if resp != nil {
|
|
logger.Debug("PUT /projects/%s/merge_requests/%d/reset_approvals returned: %d", repo.FullName, pull.Num, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to reset approvals")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetVersion returns the version of the Gitlab server this client is using.
|
|
func (g *GitlabClient) GetVersion(logger logging.SimpleLogging) (*version.Version, error) {
|
|
logger.Debug("Getting GitLab version")
|
|
versionResp, resp, err := g.Client.Version.GetVersion()
|
|
if resp != nil {
|
|
logger.Debug("GET /version returned: %d", resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// We need to strip any "-ee" or similar from the resulting version because go-version
|
|
// uses that in its constraints and it breaks the comparison we're trying
|
|
// to do for Common Mark.
|
|
split := strings.Split(versionResp.Version, "-")
|
|
parsedVersion, err := version.NewVersion(split[0])
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "parsing response to /version: %q", versionResp.Version)
|
|
}
|
|
return parsedVersion, nil
|
|
}
|
|
|
|
// SupportsCommonMark returns true if the version of Gitlab this client is
|
|
// using supports the CommonMark markdown format.
|
|
func (g *GitlabClient) SupportsCommonMark() bool {
|
|
// This function is called even if we didn't construct a gitlab client
|
|
// so we need to handle that case.
|
|
if g == nil {
|
|
return false
|
|
}
|
|
|
|
return commonMarkSupported.Check(g.Version)
|
|
}
|
|
|
|
// MustConstraint returns a constraint. It panics on error.
|
|
func MustConstraint(constraint string) version.Constraints {
|
|
c, err := version.NewConstraint(constraint)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// GetTeamNamesForUser returns the names of the GitLab groups that the user belongs to.
|
|
// The user membership is checked in each group from configuredTeams, groups
|
|
// that the Atlantis user doesn't have access to are silently ignored.
|
|
func (g *GitlabClient) GetTeamNamesForUser(logger logging.SimpleLogging, _ models.Repo, user models.User) ([]string, error) {
|
|
logger.Debug("Getting GitLab group names for user '%s'", user)
|
|
var teamNames []string
|
|
|
|
users, resp, err := g.Client.Users.ListUsers(&gitlab.ListUsersOptions{Username: &user.Username})
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return teamNames, nil
|
|
}
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "GET /users returned: %d", resp.StatusCode)
|
|
} else if len(users) == 0 {
|
|
return nil, errors.New("GET /users returned no user")
|
|
} else if len(users) > 1 {
|
|
// Theoretically impossible, just being extra safe
|
|
return nil, errors.New("GET /users returned more than 1 user")
|
|
}
|
|
userID := users[0].ID
|
|
for _, groupName := range g.ConfiguredGroups {
|
|
membership, resp, err := g.Client.GroupMembers.GetGroupMember(groupName, userID)
|
|
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "GET /groups/%s/members/%d returned: %d", groupName, userID, resp.StatusCode)
|
|
}
|
|
if resp.StatusCode == http.StatusOK && membership.State == "active" {
|
|
teamNames = append(teamNames, groupName)
|
|
}
|
|
}
|
|
return teamNames, nil
|
|
}
|
|
|
|
// GetFileContent a repository file content from VCS (which support fetch a single file from repository)
|
|
// The first return value indicates whether the repo contains a file or not
|
|
// if BaseRepo had a file, its content will placed on the second return value
|
|
func (g *GitlabClient) GetFileContent(logger logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) {
|
|
logger.Debug("Getting GitLab file content for file '%s'", fileName)
|
|
opt := gitlab.GetRawFileOptions{Ref: gitlab.Ptr(pull.HeadBranch)}
|
|
|
|
bytes, resp, err := g.Client.RepositoryFiles.GetRawFile(pull.BaseRepo.FullName, fileName, &opt)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/repository/files/%s/raw returned: %d", pull.BaseRepo.FullName, fileName, resp.StatusCode)
|
|
}
|
|
if resp != nil && resp.StatusCode == http.StatusNotFound {
|
|
return false, []byte{}, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return true, []byte{}, err
|
|
}
|
|
|
|
return true, bytes, nil
|
|
}
|
|
|
|
func (g *GitlabClient) SupportsSingleFileDownload(_ models.Repo) bool {
|
|
return true
|
|
}
|
|
|
|
func (g *GitlabClient) GetCloneURL(logger logging.SimpleLogging, _ models.VCSHostType, repo string) (string, error) {
|
|
logger.Debug("Getting GitLab clone URL for repo '%s'", repo)
|
|
project, resp, err := g.Client.Projects.GetProject(repo, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s returned: %d", repo, resp.StatusCode)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return project.HTTPURLToRepo, nil
|
|
}
|
|
|
|
func (g *GitlabClient) GetPullLabels(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
|
|
logger.Debug("Getting GitLab labels for merge request %d", pull.Num)
|
|
mr, resp, err := g.Client.MergeRequests.GetMergeRequest(repo.FullName, pull.Num, nil)
|
|
if resp != nil {
|
|
logger.Debug("GET /projects/%s/merge_requests/%d returned: %d", repo.FullName, pull.Num, resp.StatusCode)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return mr.Labels, nil
|
|
}
|