Files
atlantis/server/events/vcs/azuredevops_client.go
2025-02-16 20:22:18 +00:00

449 lines
16 KiB
Go

package vcs
import (
"context"
"fmt"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/drmaxgit/go-azuredevops/azuredevops"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs/common"
"github.com/runatlantis/atlantis/server/logging"
)
// AzureDevopsClient represents an Azure DevOps VCS client
type AzureDevopsClient struct {
Client *azuredevops.Client
ctx context.Context
UserName string
}
// NewAzureDevopsClient returns a valid Azure DevOps client.
func NewAzureDevopsClient(hostname string, userName string, token string) (*AzureDevopsClient, error) {
tp := azuredevops.BasicAuthTransport{
Username: "",
Password: strings.TrimSpace(token),
}
httpClient := tp.Client()
httpClient.Timeout = time.Second * 10
var adClient, err = azuredevops.NewClient(httpClient)
if err != nil {
return nil, err
}
if hostname != "dev.azure.com" {
baseURL := fmt.Sprintf("https://%s/", hostname)
base, err := url.Parse(baseURL)
if err != nil {
return nil, errors.Wrapf(err, "invalid azure devops hostname trying to parse %s", baseURL)
}
adClient.BaseURL = *base
}
client := &AzureDevopsClient{
Client: adClient,
UserName: userName,
ctx: context.Background(),
}
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 *AzureDevopsClient) GetModifiedFiles(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) ([]string, error) {
var files []string
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
opts := azuredevops.PullRequestGetOptions{
IncludeWorkItemRefs: true,
}
pullRequest, _, _ := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts)
targetRefName := strings.Replace(pullRequest.GetTargetRefName(), "refs/heads/", "", 1)
sourceRefName := strings.Replace(pullRequest.GetSourceRefName(), "refs/heads/", "", 1)
const pageSize = 100 // Number of files from diff call
var skip int
for {
r, resp, err := g.Client.Git.GetDiffs(g.ctx, owner, project, repoName, targetRefName, sourceRefName, &azuredevops.GitDiffListOptions{
Top: pageSize,
Skip: skip,
})
if err != nil {
return nil, errors.Wrap(err, "getting pull request")
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Wrapf(err, "http response code %d getting diff %s to %s", resp.StatusCode, sourceRefName, targetRefName)
}
for _, change := range r.Changes {
item := change.GetItem()
// Convert the path to a relative path from the repo's root.
relativePath := filepath.Clean("./" + item.GetPath())
files = append(files, relativePath)
// If the file was renamed, we'll want to run plan in the directory
// it was moved from as well.
changeType := azuredevops.Rename.String()
if change.ChangeType == &changeType {
relativePath = filepath.Clean("./" + change.GetSourceServerItem())
files = append(files, relativePath)
}
}
if len(r.Changes) < pageSize {
break // Break if we have reached the end
}
skip += pageSize // Move to next page
}
return files, nil
}
// CreateComment creates a comment on a pull request.
//
// If comment length is greater than the max comment length we split into
// multiple comments.
func (g *AzureDevopsClient) CreateComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, comment string, command string) error { //nolint: revive
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"
// maxCommentLength is the maximum number of chars allowed in a single comment
// This length was copied from the Github client - haven't found documentation
// or tested limit in Azure DevOps.
const maxCommentLength = 150000
comments := common.SplitComment(comment, maxCommentLength, sepEnd, sepStart, 0, "")
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
for i := range comments {
commentType := "text"
parentCommentID := 0
prComment := azuredevops.Comment{
CommentType: &commentType,
Content: &comments[i],
ParentCommentID: &parentCommentID,
}
prComments := []*azuredevops.Comment{&prComment}
body := azuredevops.GitPullRequestCommentThread{
Comments: prComments,
}
_, _, err := g.Client.PullRequests.CreateComments(g.ctx, owner, project, repoName, pullNum, &body)
if err != nil {
return err
}
}
return nil
}
func (g *AzureDevopsClient) ReactToComment(logger logging.SimpleLogging, repo models.Repo, pullNum int, commentID int64, reaction string) error { //nolint: revive
return nil
}
func (g *AzureDevopsClient) HidePrevCommandComments(logger logging.SimpleLogging, repo models.Repo, pullNum int, command string, dir string) error { //nolint: revive
return nil
}
// PullIsApproved returns true if the merge request was approved by another reviewer.
// https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops#require-a-minimum-number-of-reviewers
func (g *AzureDevopsClient) PullIsApproved(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest) (approvalStatus models.ApprovalStatus, err error) {
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
opts := azuredevops.PullRequestGetOptions{
IncludeWorkItemRefs: true,
}
adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts)
if err != nil {
return approvalStatus, errors.Wrap(err, "getting pull request")
}
for _, review := range adPull.Reviewers {
if review == nil {
continue
}
if review.IdentityRef.GetUniqueName() == adPull.GetCreatedBy().GetUniqueName() {
continue
}
if review.GetVote() == azuredevops.VoteApproved || review.GetVote() == azuredevops.VoteApprovedWithSuggestions {
return models.ApprovalStatus{
IsApproved: true,
}, nil
}
}
return approvalStatus, nil
}
func (g *AzureDevopsClient) DiscardReviews(repo models.Repo, pull models.PullRequest) error { //nolint: revive
// TODO implement
return nil
}
// PullIsMergeable returns true if the merge request can be merged.
func (g *AzureDevopsClient) PullIsMergeable(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, _ string, _ []string) (bool, error) { //nolint: revive
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
opts := azuredevops.PullRequestGetOptions{IncludeWorkItemRefs: true}
adPull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, pull.Num, &opts)
if err != nil {
return false, errors.Wrap(err, "getting pull request")
}
if *adPull.MergeStatus != azuredevops.MergeSucceeded.String() {
return false, nil
}
if *adPull.IsDraft {
return false, nil
}
if *adPull.Status != azuredevops.PullActive.String() {
return false, nil
}
projectID := *adPull.Repository.Project.ID
artifactID := g.Client.PolicyEvaluations.GetPullRequestArtifactID(projectID, pull.Num)
policyEvaluations, _, err := g.Client.PolicyEvaluations.List(g.ctx, owner, project, artifactID, &azuredevops.PolicyEvaluationsListOptions{})
if err != nil {
return false, errors.Wrap(err, "getting policy evaluations")
}
for _, policyEvaluation := range policyEvaluations {
if !*policyEvaluation.Configuration.IsEnabled || *policyEvaluation.Configuration.IsDeleted {
continue
}
// Ignore the Atlantis status, even if its set as a blocker.
// This status should not be considered when evaluating if the pull request can be applied.
settings := (policyEvaluation.Configuration.Settings).(map[string]interface{})
if genre, ok := settings["statusGenre"]; ok && genre == "Atlantis Bot/atlantis" {
if name, ok := settings["statusName"]; ok && name == "apply" {
continue
}
}
if *policyEvaluation.Configuration.IsBlocking && *policyEvaluation.Status != azuredevops.PolicyEvaluationApproved {
return false, nil
}
}
return true, nil
}
// GetPullRequest returns the pull request.
func (g *AzureDevopsClient) GetPullRequest(logger logging.SimpleLogging, repo models.Repo, num int) (*azuredevops.GitPullRequest, error) {
opts := azuredevops.PullRequestGetOptions{
IncludeWorkItemRefs: true,
}
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
pull, _, err := g.Client.PullRequests.GetWithRepo(g.ctx, owner, project, repoName, num, &opts)
return pull, err
}
// UpdateStatus updates the build status of a commit.
func (g *AzureDevopsClient) UpdateStatus(logger logging.SimpleLogging, repo models.Repo, pull models.PullRequest, state models.CommitStatus, src string, description string, url string) error {
adState := azuredevops.GitError.String()
switch state {
case models.PendingCommitStatus:
adState = azuredevops.GitPending.String()
case models.SuccessCommitStatus:
adState = azuredevops.GitSucceeded.String()
case models.FailedCommitStatus:
adState = azuredevops.GitFailed.String()
}
logger.Info("Updating Azure DevOps commit status for '%s' to '%s'", src, adState)
status := azuredevops.GitPullRequestStatus{}
status.Context = GitStatusContextFromSrc(src)
status.Description = &description
status.State = &adState
if url != "" {
status.TargetURL = &url
}
owner, project, repoName := SplitAzureDevopsRepoFullName(repo.FullName)
opts := azuredevops.PullRequestListOptions{}
source, resp, err := g.Client.PullRequests.Get(g.ctx, owner, project, pull.Num, &opts)
if err != nil {
return errors.Wrap(err, "getting pull request")
}
if resp.StatusCode != http.StatusOK {
return errors.Errorf("http response code %d getting pull request", resp.StatusCode)
}
if source.GetSupportsIterations() {
opts := azuredevops.PullRequestIterationsListOptions{}
iterations, resp, err := g.Client.PullRequests.ListIterations(g.ctx, owner, project, repoName, pull.Num, &opts)
if err != nil {
return errors.Wrap(err, "listing pull request iterations")
}
if resp.StatusCode != http.StatusOK {
return errors.Errorf("http response code %d listing pull request iterations", resp.StatusCode)
}
for _, iteration := range iterations {
if sourceRef := iteration.GetSourceRefCommit(); sourceRef != nil {
if *sourceRef.CommitID == pull.HeadCommit {
status.IterationID = iteration.ID
break
}
}
}
if iterationID := status.IterationID; iterationID != nil {
if !(*iterationID >= 1) {
return errors.New("supportsIterations was true but got invalid iteration ID or no matching iteration commit SHA was found")
}
}
}
_, resp, err = g.Client.PullRequests.CreateStatus(g.ctx, owner, project, repoName, pull.Num, &status)
if err != nil {
return errors.Wrap(err, "creating pull request status")
}
if resp.StatusCode != http.StatusOK {
return errors.Errorf("http response code %d creating pull request status", resp.StatusCode)
}
return err
}
// MergePull merges the merge request using the default no fast-forward strategy
// If the user has set a branch policy that disallows no fast-forward, the merge will fail
// until we handle branch policies
// https://docs.microsoft.com/en-us/azure/devops/repos/git/branch-policies?view=azure-devops
func (g *AzureDevopsClient) MergePull(logger logging.SimpleLogging, pull models.PullRequest, pullOptions models.PullRequestOptions) error {
owner, project, repoName := SplitAzureDevopsRepoFullName(pull.BaseRepo.FullName)
descriptor := "Atlantis Terraform Pull Request Automation"
userID, err := g.Client.UserEntitlements.GetUserID(g.ctx, g.UserName, owner)
if err != nil {
return errors.Wrapf(err, "Getting user id failed. User name: %s Organization %s ", g.UserName, owner)
}
if userID == nil {
return fmt.Errorf("the user %s is not found in the organization %s", g.UserName, owner)
}
imageURL := "https://raw.githubusercontent.com/runatlantis/atlantis/main/runatlantis.io/public/hero.png"
id := azuredevops.IdentityRef{
Descriptor: &descriptor,
ID: userID,
ImageURL: &imageURL,
}
// Set default pull request completion options
mcm := azuredevops.NoFastForward.String()
twi := new(bool)
*twi = true
completionOpts := azuredevops.GitPullRequestCompletionOptions{
BypassPolicy: new(bool),
BypassReason: azuredevops.String(""),
DeleteSourceBranch: &pullOptions.DeleteSourceBranchOnMerge,
MergeCommitMessage: azuredevops.String(common.AutomergeCommitMsg(pull.Num)),
MergeStrategy: &mcm,
SquashMerge: new(bool),
TransitionWorkItems: twi,
TriggeredByAutoComplete: new(bool),
}
// Construct request body from supplied parameters
mergePull := new(azuredevops.GitPullRequest)
mergePull.AutoCompleteSetBy = &id
mergePull.CompletionOptions = &completionOpts
mergeResult, _, err := g.Client.PullRequests.Merge(
g.ctx,
owner,
project,
repoName,
pull.Num,
mergePull,
completionOpts,
id,
)
if err != nil {
return errors.Wrap(err, "merging pull request")
}
if *mergeResult.MergeStatus != azuredevops.MergeSucceeded.String() {
return fmt.Errorf("could not merge pull request: %s", mergeResult.GetMergeFailureMessage())
}
return nil
}
// MarkdownPullLink specifies the string used in a pull request comment to reference another pull request.
func (g *AzureDevopsClient) MarkdownPullLink(pull models.PullRequest) (string, error) {
return fmt.Sprintf("!%d", pull.Num), nil
}
// SplitAzureDevopsRepoFullName splits a repo full name up into its owner,
// repo and project name segments. If the repoFullName is malformed, may
// return empty strings for owner, repo, or project. Azure DevOps uses
// repoFullName format owner/project/repo.
//
// Ex. runatlantis/atlantis => (runatlantis, atlantis)
//
// gitlab/subgroup/runatlantis/atlantis => (gitlab/subgroup/runatlantis, atlantis)
// azuredevops/project/atlantis => (azuredevops, project, atlantis)
func SplitAzureDevopsRepoFullName(repoFullName string) (owner string, project string, repo string) {
firstSlashIdx := strings.Index(repoFullName, "/")
lastSlashIdx := strings.LastIndex(repoFullName, "/")
slashCount := strings.Count(repoFullName, "/")
if lastSlashIdx == -1 || lastSlashIdx == len(repoFullName)-1 {
return "", "", ""
}
if firstSlashIdx != lastSlashIdx && slashCount == 2 {
return repoFullName[:firstSlashIdx],
repoFullName[firstSlashIdx+1 : lastSlashIdx],
repoFullName[lastSlashIdx+1:]
}
return repoFullName[:lastSlashIdx], "", repoFullName[lastSlashIdx+1:]
}
// GetTeamNamesForUser returns the names of the teams or groups that the user belongs to (in the organization the repository belongs to).
func (g *AzureDevopsClient) GetTeamNamesForUser(_ logging.SimpleLogging, _ models.Repo, _ models.User) ([]string, error) { //nolint: revive
return nil, nil
}
func (g *AzureDevopsClient) SupportsSingleFileDownload(repo models.Repo) bool { //nolint: revive
return false
}
func (g *AzureDevopsClient) GetFileContent(_ logging.SimpleLogging, pull models.PullRequest, fileName string) (bool, []byte, error) { //nolint: revive
return false, []byte{}, fmt.Errorf("not implemented")
}
// GitStatusContextFromSrc parses an Atlantis formatted src string into a context suitable
// for the status update API. In the AzureDevops branch policy UI there is a single string
// field used to drive these contexts where all text preceding the final '/' character is
// treated as the 'genre'.
func GitStatusContextFromSrc(src string) *azuredevops.GitStatusContext {
lastSlashIdx := strings.LastIndex(src, "/")
genre := "Atlantis Bot"
name := src
if lastSlashIdx != -1 {
genre = fmt.Sprintf("%s/%s", genre, src[:lastSlashIdx])
name = src[lastSlashIdx+1:]
}
return &azuredevops.GitStatusContext{
Name: &name,
Genre: &genre,
}
}
func (g *AzureDevopsClient) GetCloneURL(_ logging.SimpleLogging, VCSHostType models.VCSHostType, repo string) (string, error) { //nolint: revive
return "", fmt.Errorf("not yet implemented")
}
func (g *AzureDevopsClient) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) {
return nil, fmt.Errorf("not yet implemented")
}