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

1175 lines
41 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 events
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path"
"strings"
giteasdk "code.gitea.io/sdk/gitea"
"github.com/drmaxgit/go-azuredevops/azuredevops"
"github.com/go-playground/validator/v10"
"github.com/google/go-github/v68/github"
lru "github.com/hashicorp/golang-lru/v2"
"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/bitbucketcloud"
"github.com/runatlantis/atlantis/server/events/vcs/bitbucketserver"
"github.com/runatlantis/atlantis/server/events/vcs/gitea"
"github.com/runatlantis/atlantis/server/logging"
gitlab "gitlab.com/gitlab-org/api/client-go"
)
const gitlabPullOpened = "opened"
const usagesCols = 90
var lastBitbucketSha, _ = lru.New[string, string](300)
// PullCommand is a command to run on a pull request.
type PullCommand interface {
// Dir is the path relative to the repo root to run the command in.
// Will never end in "/". If empty then the comment specified no directory.
Dir() string
// CommandName is the name of the command we're running.
CommandName() command.Name
// SubCommandName is the subcommand name of the command we're running.
SubCommandName() string
// IsVerbose is true if the output of this command should be verbose.
IsVerbose() bool
// IsAutoplan is true if this is an autoplan command vs. a comment command.
IsAutoplan() bool
}
// PolicyCheckCommand is a policy_check command that is automatically triggered
// after successful plan command.
type PolicyCheckCommand struct{}
// CommandName is policy_check.
func (c PolicyCheckCommand) CommandName() command.Name {
return command.PolicyCheck
}
// SubCommandName is a subcommand for policy_check.
func (c PolicyCheckCommand) SubCommandName() string {
return ""
}
// Dir is empty
func (c PolicyCheckCommand) Dir() string {
return ""
}
// IsVerbose is false for policy_check commands.
func (c PolicyCheckCommand) IsVerbose() bool {
return false
}
// IsAutoplan is true for policy_check commands.
func (c PolicyCheckCommand) IsAutoplan() bool {
return false
}
// AutoplanCommand is a plan command that is automatically triggered when a
// pull request is opened or updated.
type AutoplanCommand struct{}
// CommandName is plan.
func (c AutoplanCommand) CommandName() command.Name {
return command.Plan
}
// SubCommandName is a subcommand for auto plan.
func (c AutoplanCommand) SubCommandName() string {
return ""
}
// Dir is empty
func (c AutoplanCommand) Dir() string {
return ""
}
// IsVerbose is false for autoplan commands.
func (c AutoplanCommand) IsVerbose() bool {
return false
}
// IsAutoplan is true for autoplan commands (obviously).
func (c AutoplanCommand) IsAutoplan() bool {
return true
}
// CommentCommand is a command that was triggered by a pull request comment.
type CommentCommand struct {
// RepoRelDir is the path relative to the repo root to run the command in.
// Will never end in "/". If empty then the comment specified no directory.
RepoRelDir string
// Flags are the extra arguments appended to the comment,
// ex. atlantis plan -- -target=resource
Flags []string
// Name is the name of the command the comment specified.
Name command.Name
// SubName is the name of the sub command the comment specified.
SubName string
// AutoMergeDisabled is true if the command should not automerge after apply.
AutoMergeDisabled bool
// AutoMergeMethod specified the merge method for the VCS if automerge enabled.
AutoMergeMethod string
// Verbose is true if the command should output verbosely.
Verbose bool
// Workspace is the name of the Terraform workspace to run the command in.
// If empty then the comment specified no workspace.
Workspace string
// ProjectName is the name of a project to run the command on. It refers to a
// project specified in an atlantis.yaml file.
// If empty then the comment specified no project.
ProjectName string
// PolicySet is the name of a policy set to run an approval on.
PolicySet string
// ClearPolicyApproval is true if approvals should be cleared out for specified policies.
ClearPolicyApproval bool
}
// IsForSpecificProject returns true if the command is for a specific dir, workspace
// or project name. Otherwise it's a command like "atlantis plan" or "atlantis
// apply".
func (c CommentCommand) IsForSpecificProject() bool {
return c.RepoRelDir != "" || c.Workspace != "" || c.ProjectName != ""
}
// Dir returns the dir of this command.
func (c CommentCommand) Dir() string {
return c.RepoRelDir
}
// CommandName returns the name of this command.
func (c CommentCommand) CommandName() command.Name {
return c.Name
}
// SubCommandName returns the name of this subcommand.
func (c CommentCommand) SubCommandName() string {
return c.SubName
}
// IsVerbose is true if the command should give verbose output.
func (c CommentCommand) IsVerbose() bool {
return c.Verbose
}
// IsAutoplan will be false for comment commands.
func (c CommentCommand) IsAutoplan() bool {
return false
}
// String returns a string representation of the command.
func (c CommentCommand) String() string {
return fmt.Sprintf("command=%q, verbose=%t, dir=%q, workspace=%q, project=%q, policyset=%q, auto-merge-disabled=%t, auto-merge-method=%s, clear-policy-approval=%t, flags=%q", c.Name.String(), c.Verbose, c.RepoRelDir, c.Workspace, c.ProjectName, c.PolicySet, c.AutoMergeDisabled, c.AutoMergeMethod, c.ClearPolicyApproval, strings.Join(c.Flags, ","))
}
// NewCommentCommand constructs a CommentCommand, setting all missing fields to defaults.
func NewCommentCommand(repoRelDir string, flags []string, name command.Name, subName string, verbose, autoMergeDisabled bool, autoMergeMethod string, workspace string, project string, policySet string, clearPolicyApproval bool) *CommentCommand {
// If repoRelDir was empty we want to keep it that way to indicate that it
// wasn't specified in the comment.
if repoRelDir != "" {
repoRelDir = path.Clean(repoRelDir)
if repoRelDir == "/" {
repoRelDir = "."
}
}
return &CommentCommand{
RepoRelDir: repoRelDir,
Flags: flags,
Name: name,
SubName: subName,
Verbose: verbose,
Workspace: workspace,
AutoMergeDisabled: autoMergeDisabled,
AutoMergeMethod: autoMergeMethod,
ProjectName: project,
PolicySet: policySet,
ClearPolicyApproval: clearPolicyApproval,
}
}
//go:generate pegomock generate github.com/runatlantis/atlantis/server/events --package mocks -o mocks/mock_event_parsing.go EventParsing
// EventParsing parses webhook events from different VCS hosts into their
// respective Atlantis models.
// todo: rename to VCSParsing or the like because this also parses API responses #refactor
type EventParsing interface {
// ParseGithubIssueCommentEvent parses GitHub pull request comment events.
// baseRepo is the repo that the pull request will be merged into.
// user is the pull request author.
// pullNum is the number of the pull request that triggered the webhook.
ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) (
baseRepo models.Repo, user models.User, pullNum int, err error)
// ParseGithubPull parses the response from the GitHub API endpoint (not
// from a webhook) that returns a pull request.
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
ParseGithubPull(logger logging.SimpleLogging, ghPull *github.PullRequest) (
pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error)
// ParseGithubPullEvent parses GitHub pull request events.
// pull is the parsed pull request.
// pullEventType is the type of event, for example opened/closed.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) (
pull models.PullRequest, pullEventType models.PullRequestEventType,
baseRepo models.Repo, headRepo models.Repo, user models.User, err error)
// ParseGithubRepo parses the response from the GitHub API endpoint that
// returns a repo into the Atlantis model.
ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error)
// ParseGitlabMergeRequestEvent parses GitLab merge request events.
// pull is the parsed merge request.
// pullEventType is the type of event, for example opened/closed.
// baseRepo is the repo the merge request will be merged into.
// headRepo is the repo the merge request branch is from.
// user is the pull request author.
ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) (
pull models.PullRequest, pullEventType models.PullRequestEventType,
baseRepo models.Repo, headRepo models.Repo, user models.User, err error)
// ParseGitlabMergeRequestUpdateEvent dives deeper into Gitlab merge request update events to check
// if Atlantis should handle events or not. Atlantis should ignore events which dont change the MR content
// We assume that 1 event carries multiple events, so firstly need to check for events triggering Atlantis planning
// Default 'unknown' event to 'models.UpdatedPullEvent'
ParseGitlabMergeRequestUpdateEvent(event gitlab.MergeEvent) models.PullRequestEventType
// ParseGitlabMergeRequestCommentEvent parses GitLab merge request comment
// events.
// baseRepo is the repo the merge request will be merged into.
// headRepo is the repo the merge request branch is from.
// user is the pull request author.
ParseGitlabMergeRequestCommentEvent(event gitlab.MergeCommentEvent) (
baseRepo models.Repo, headRepo models.Repo, commentID int, user models.User, err error)
// ParseGitlabMergeRequest parses the response from the GitLab API endpoint
// that returns a merge request.
ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest
ParseAPIPlanRequest(vcsHostType models.VCSHostType, path string, cloneURL string) (baseRepo models.Repo, err error)
// ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket
// Cloud (bitbucket.org).
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
ParseBitbucketCloudPullEvent(body []byte) (
pull models.PullRequest, baseRepo models.Repo,
headRepo models.Repo, user models.User, err error)
// ParseBitbucketCloudPullCommentEvent parses a pull request comment event
// from Bitbucket Cloud (bitbucket.org).
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
// comment is the comment that triggered the event.
ParseBitbucketCloudPullCommentEvent(body []byte) (
pull models.PullRequest, baseRepo models.Repo,
headRepo models.Repo, user models.User, comment string, err error)
// GetBitbucketCloudPullEventType returns the type of the pull request
// event given the Bitbucket Cloud header.
GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) models.PullRequestEventType
// ParseBitbucketServerPullEvent parses a pull request event from Bitbucket
// Server.
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
ParseBitbucketServerPullEvent(body []byte) (
pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo,
user models.User, err error)
// ParseBitbucketServerPullCommentEvent parses a pull request comment event
// from Bitbucket Server.
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
// comment is the comment that triggered the event.
ParseBitbucketServerPullCommentEvent(body []byte) (
pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo,
user models.User, comment string, err error)
// GetBitbucketServerPullEventType returns the type of the pull request
// event given the Bitbucket Server header.
GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType
// ParseAzureDevopsPull parses the response from the Azure DevOps API endpoint (not
// from a webhook) that returns a pull request.
// pull is the parsed pull request.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
ParseAzureDevopsPull(adPull *azuredevops.GitPullRequest) (
pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error)
// ParseAzureDevopsPullEvent parses Azure DevOps pull request events.
// pull is the parsed pull request.
// pullEventType is the type of event, for example opened/closed.
// baseRepo is the repo the pull request will be merged into.
// headRepo is the repo the pull request branch is from.
// user is the pull request author.
ParseAzureDevopsPullEvent(pullEvent azuredevops.Event) (
pull models.PullRequest, pullEventType models.PullRequestEventType,
baseRepo models.Repo, headRepo models.Repo, user models.User, err error)
// ParseAzureDevopsRepo parses the response from the Azure DevOps API endpoint that
// returns a repo into the Atlantis model.
ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error)
ParseGiteaPullRequestEvent(event giteasdk.PullRequest) (
pull models.PullRequest, pullEventType models.PullRequestEventType,
baseRepo models.Repo, headRepo models.Repo, user models.User, err error)
ParseGiteaIssueCommentEvent(event gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error)
ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error)
}
// EventParser parses VCS events.
type EventParser struct {
GithubUser string
GithubToken string
GithubTokenFile string
GitlabUser string
GitlabToken string
GiteaUser string
GiteaToken string
AllowDraftPRs bool
BitbucketUser string
BitbucketToken string
BitbucketServerURL string
AzureDevopsToken string
AzureDevopsUser string
}
func (e *EventParser) ParseAPIPlanRequest(vcsHostType models.VCSHostType, repoFullName string, cloneURL string) (models.Repo, error) {
switch vcsHostType {
case models.Github:
token := e.GithubToken
if e.GithubTokenFile != "" {
content, err := os.ReadFile(e.GithubTokenFile)
if err != nil {
return models.Repo{}, fmt.Errorf("failed reading github token file: %w", err)
}
token = string(content)
}
return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GithubUser, token)
case models.Gitea:
return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GiteaUser, e.GiteaToken)
case models.Gitlab:
return models.NewRepo(vcsHostType, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken)
}
return models.Repo{}, fmt.Errorf("not implemented")
}
// GetBitbucketCloudPullEventType returns the type of the pull request
// event given the Bitbucket Cloud header.
func (e *EventParser) GetBitbucketCloudPullEventType(eventTypeHeader string, sha string, pr string) models.PullRequestEventType {
switch eventTypeHeader {
case bitbucketcloud.PullCreatedHeader:
lastBitbucketSha.Add(pr, sha)
return models.OpenedPullEvent
case bitbucketcloud.PullUpdatedHeader:
lastSha, _ := lastBitbucketSha.Get(pr)
if sha == lastSha {
// No change, ignore
return models.OtherPullEvent
}
lastBitbucketSha.Add(pr, sha)
return models.UpdatedPullEvent
case bitbucketcloud.PullFulfilledHeader, bitbucketcloud.PullRejectedHeader:
return models.ClosedPullEvent
}
return models.OtherPullEvent
}
// ParseBitbucketCloudPullCommentEvent parses a pull request comment event
// from Bitbucket Cloud (bitbucket.org).
// See EventParsing for return value docs.
func (e *EventParser) ParseBitbucketCloudPullCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) {
var event bitbucketcloud.CommentEvent
if err = json.Unmarshal(body, &event); err != nil {
err = errors.Wrap(err, "parsing json")
return
}
if err = validator.New().Struct(event); err != nil {
err = errors.Wrapf(err, "API response %q was missing fields", string(body))
return
}
pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData)
comment = *event.Comment.Content.Raw
return
}
func (e *EventParser) parseCommonBitbucketCloudEventData(event bitbucketcloud.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
var prState models.PullRequestState
switch *event.PullRequest.State {
case "OPEN":
prState = models.OpenPullState
case "MERGED":
prState = models.ClosedPullState
case "SUPERSEDED":
prState = models.ClosedPullState
case "DECLINED":
prState = models.ClosedPullState
default:
err = fmt.Errorf("unable to determine pull request state from %qthis is a bug", *event.PullRequest.State)
return
}
headRepo, err = models.NewRepo(
models.BitbucketCloud,
*event.PullRequest.Source.Repository.FullName,
*event.PullRequest.Source.Repository.Links.HTML.HREF,
e.BitbucketUser,
e.BitbucketToken)
if err != nil {
return
}
baseRepo, err = models.NewRepo(
models.BitbucketCloud,
*event.Repository.FullName,
*event.Repository.Links.HTML.HREF,
e.BitbucketUser,
e.BitbucketToken)
if err != nil {
return
}
pull = models.PullRequest{
Num: *event.PullRequest.ID,
HeadCommit: *event.PullRequest.Source.Commit.Hash,
URL: *event.PullRequest.Links.HTML.HREF,
HeadBranch: *event.PullRequest.Source.Branch.Name,
BaseBranch: *event.PullRequest.Destination.Branch.Name,
Author: *event.Actor.AccountID,
State: prState,
BaseRepo: baseRepo,
}
user = models.User{
Username: *event.Actor.AccountID,
}
return
}
// ParseBitbucketCloudPullEvent parses a pull request event from Bitbucket
// Cloud (bitbucket.org).
// See EventParsing for return value docs.
func (e *EventParser) ParseBitbucketCloudPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
var event bitbucketcloud.PullRequestEvent
if err = json.Unmarshal(body, &event); err != nil {
err = errors.Wrap(err, "parsing json")
return
}
if err = validator.New().Struct(event); err != nil {
err = errors.Wrapf(err, "API response %q was missing fields", string(body))
return
}
pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketCloudEventData(event.CommonEventData)
return
}
// ParseGithubIssueCommentEvent parses GitHub pull request comment events.
// See EventParsing for return value docs.
func (e *EventParser) ParseGithubIssueCommentEvent(logger logging.SimpleLogging, comment *github.IssueCommentEvent) (baseRepo models.Repo, user models.User, pullNum int, err error) {
baseRepo, err = e.ParseGithubRepo(comment.Repo)
if err != nil {
return
}
if comment.Comment == nil || comment.Comment.User.GetLogin() == "" {
err = errors.New("comment.user.login is null")
return
}
commenterUsername := comment.Comment.User.GetLogin()
user = models.User{
Username: commenterUsername,
}
pullNum = comment.Issue.GetNumber()
if pullNum == 0 {
err = errors.New("issue.number is null")
return
}
return
}
// ParseGithubPullEvent parses GitHub pull request events.
// See EventParsing for return value docs.
func (e *EventParser) ParseGithubPullEvent(logger logging.SimpleLogging, pullEvent *github.PullRequestEvent) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
if pullEvent.PullRequest == nil {
err = errors.New("pull_request is null")
return
}
pull, baseRepo, headRepo, err = e.ParseGithubPull(logger, pullEvent.PullRequest)
if err != nil {
return
}
if pullEvent.Sender == nil {
err = errors.New("sender is null")
return
}
senderUsername := pullEvent.Sender.GetLogin()
if senderUsername == "" {
err = errors.New("sender.login is null")
return
}
action := pullEvent.GetAction()
// If it's a draft PR we ignore it for auto-planning if configured to do so
// however it's still possible for users to run plan on it manually via a
// comment so if any draft PR is closed we still need to check if we need
// to delete its locks.
if pullEvent.GetPullRequest().GetDraft() && pullEvent.GetAction() != "closed" && !e.AllowDraftPRs {
action = "other"
}
switch action {
case "opened":
pullEventType = models.OpenedPullEvent
case "ready_for_review":
// when an author takes a PR out of 'draft' state a 'ready_for_review'
// event is triggered. We want atlantis to treat this as a freshly opened PR
pullEventType = models.OpenedPullEvent
case "synchronize":
pullEventType = models.UpdatedPullEvent
case "closed":
pullEventType = models.ClosedPullEvent
default:
pullEventType = models.OtherPullEvent
}
user = models.User{Username: senderUsername}
return
}
// ParseGithubPull parses the response from the GitHub API endpoint (not
// from a webhook) that returns a pull request.
// See EventParsing for return value docs.
func (e *EventParser) ParseGithubPull(logger logging.SimpleLogging, pull *github.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) {
commit := pull.Head.GetSHA()
if commit == "" {
err = errors.New("head.sha is null")
return
}
url := pull.GetHTMLURL()
if url == "" {
err = errors.New("html_url is null")
return
}
headBranch := pull.Head.GetRef()
if headBranch == "" {
err = errors.New("head.ref is null")
return
}
baseBranch := pull.Base.GetRef()
if baseBranch == "" {
err = errors.New("base.ref is null")
return
}
authorUsername := pull.User.GetLogin()
if authorUsername == "" {
err = errors.New("user.login is null")
return
}
num := pull.GetNumber()
if num == 0 {
err = errors.New("number is null")
return
}
baseRepo, err = e.ParseGithubRepo(pull.Base.Repo)
if err != nil {
return
}
headRepo, err = e.ParseGithubRepo(pull.Head.Repo)
if err != nil {
return
}
pullState := models.ClosedPullState
if pull.GetState() == "open" {
pullState = models.OpenPullState
}
pullModel = models.PullRequest{
Author: authorUsername,
HeadBranch: headBranch,
HeadCommit: commit,
URL: url,
Num: num,
State: pullState,
BaseRepo: baseRepo,
BaseBranch: baseBranch,
}
return
}
// ParseGithubRepo parses the response from the GitHub API endpoint that
// returns a repo into the Atlantis model.
// See EventParsing for return value docs.
func (e *EventParser) ParseGithubRepo(ghRepo *github.Repository) (models.Repo, error) {
token := e.GithubToken
if e.GithubTokenFile != "" {
content, err := os.ReadFile(e.GithubTokenFile)
if err != nil {
return models.Repo{}, fmt.Errorf("failed reading github token file: %w", err)
}
token = string(content)
}
return models.NewRepo(models.Github, ghRepo.GetFullName(), ghRepo.GetCloneURL(), e.GithubUser, token)
}
// ParseGiteaRepo parses the response from the Gitea API endpoint that
// returns a repo into the Atlantis model.
// See EventParsing for return value docs.
func (e *EventParser) ParseGiteaRepo(repo giteasdk.Repository) (models.Repo, error) {
return models.NewRepo(models.Gitea, repo.FullName, repo.CloneURL, e.GiteaUser, e.GiteaToken)
}
// ParseGitlabMergeRequestUpdateEvent dives deeper into Gitlab merge request update events
func (e *EventParser) ParseGitlabMergeRequestUpdateEvent(event gitlab.MergeEvent) models.PullRequestEventType {
// New commit to opened MR
if len(event.ObjectAttributes.OldRev) > 0 ||
// Check for MR that has been marked as ready
(strings.HasPrefix(event.Changes.Title.Previous, "Draft:") && !strings.HasPrefix(event.Changes.Title.Current, "Draft:")) {
return models.UpdatedPullEvent
}
return models.OtherPullEvent
}
// ParseGitlabMergeRequestEvent parses GitLab merge request events.
// pull is the parsed merge request.
// See EventParsing for return value docs.
func (e *EventParser) ParseGitlabMergeRequestEvent(event gitlab.MergeEvent) (pull models.PullRequest, eventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
modelState := models.ClosedPullState
if event.ObjectAttributes.State == gitlabPullOpened {
modelState = models.OpenPullState
}
// GitLab also has a "merged" state, but we map that to Closed so we don't
// need to check for it.
baseRepo, err = models.NewRepo(models.Gitlab, event.Project.PathWithNamespace, event.Project.GitHTTPURL, e.GitlabUser, e.GitlabToken)
if err != nil {
return
}
headRepo, err = models.NewRepo(models.Gitlab, event.ObjectAttributes.Source.PathWithNamespace, event.ObjectAttributes.Source.GitHTTPURL, e.GitlabUser, e.GitlabToken)
if err != nil {
return
}
pull = models.PullRequest{
URL: event.ObjectAttributes.URL,
Author: event.User.Username,
Num: event.ObjectAttributes.IID,
HeadCommit: event.ObjectAttributes.LastCommit.ID,
HeadBranch: event.ObjectAttributes.SourceBranch,
BaseBranch: event.ObjectAttributes.TargetBranch,
State: modelState,
BaseRepo: baseRepo,
}
// If it's a draft PR we ignore it for auto-planning if configured to do so
// however it's still possible for users to run plan on it manually via a
// comment so if any draft PR is closed we still need to check if we need
// to delete its locks.
if event.ObjectAttributes.WorkInProgress && event.ObjectAttributes.Action != "close" && !e.AllowDraftPRs {
eventType = models.OtherPullEvent
} else {
switch event.ObjectAttributes.Action {
case "open":
eventType = models.OpenedPullEvent
case "update":
eventType = e.ParseGitlabMergeRequestUpdateEvent(event)
case "merge", "close":
eventType = models.ClosedPullEvent
default:
eventType = models.OtherPullEvent
}
}
user = models.User{
Username: event.User.Username,
}
return
}
// ParseGitlabMergeRequestCommentEvent parses GitLab merge request comment
// events.
// See EventParsing for return value docs.
func (e *EventParser) ParseGitlabMergeRequestCommentEvent(event gitlab.MergeCommentEvent) (baseRepo models.Repo, headRepo models.Repo, commentID int, user models.User, err error) {
// Parse the base repo first.
repoFullName := event.Project.PathWithNamespace
cloneURL := event.Project.GitHTTPURL
commentID = event.ObjectAttributes.ID
baseRepo, err = models.NewRepo(models.Gitlab, repoFullName, cloneURL, e.GitlabUser, e.GitlabToken)
if err != nil {
return
}
user = models.User{
Username: event.User.Username,
}
// Now parse the head repo.
headRepoFullName := event.MergeRequest.Source.PathWithNamespace
headCloneURL := event.MergeRequest.Source.GitHTTPURL
headRepo, err = models.NewRepo(models.Gitlab, headRepoFullName, headCloneURL, e.GitlabUser, e.GitlabToken)
return
}
func (e *EventParser) ParseGiteaIssueCommentEvent(comment gitea.GiteaIssueCommentPayload) (baseRepo models.Repo, user models.User, pullNum int, err error) {
baseRepo, err = e.ParseGiteaRepo(comment.Repository)
if err != nil {
return
}
if comment.Comment.Body == "" || comment.Comment.Poster.UserName == "" {
err = errors.New("comment.user.login is null")
return
}
commenterUsername := comment.Comment.Poster.UserName
user = models.User{
Username: commenterUsername,
}
pullNum = int(comment.Issue.Index)
if pullNum == 0 {
err = errors.New("issue.number is null")
return
}
return
}
// ParseGitlabMergeRequest parses the merge requests and returns a pull request
// model. We require passing in baseRepo because we can't get this information
// from the merge request. The only caller of this function already has that
// data so we can construct the pull request object correctly.
func (e *EventParser) ParseGitlabMergeRequest(mr *gitlab.MergeRequest, baseRepo models.Repo) models.PullRequest {
pullState := models.ClosedPullState
if mr.State == gitlabPullOpened {
pullState = models.OpenPullState
}
// GitLab also has a "merged" state, but we map that to Closed so we don't
// need to check for it.
return models.PullRequest{
URL: mr.WebURL,
Author: mr.Author.Username,
Num: mr.IID,
HeadCommit: mr.SHA,
HeadBranch: mr.SourceBranch,
BaseBranch: mr.TargetBranch,
State: pullState,
BaseRepo: baseRepo,
}
}
// GetBitbucketServerPullEventType returns the type of the pull request
// event given the Bitbucket Server header.
func (e *EventParser) GetBitbucketServerPullEventType(eventTypeHeader string) models.PullRequestEventType {
switch eventTypeHeader {
// PullFromRefUpdatedHeader event occurs on OPEN state pull request
// so no additional checks are needed.
case bitbucketserver.PullCreatedHeader, bitbucketserver.PullFromRefUpdatedHeader:
return models.OpenedPullEvent
case bitbucketserver.PullMergedHeader, bitbucketserver.PullDeclinedHeader, bitbucketserver.PullDeletedHeader:
return models.ClosedPullEvent
}
return models.OtherPullEvent
}
// ParseBitbucketServerPullCommentEvent parses a pull request comment event
// from Bitbucket Server.
// See EventParsing for return value docs.
func (e *EventParser) ParseBitbucketServerPullCommentEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, comment string, err error) {
var event bitbucketserver.CommentEvent
if err = json.Unmarshal(body, &event); err != nil {
err = errors.Wrap(err, "parsing json")
return
}
if err = validator.New().Struct(event); err != nil {
err = errors.Wrapf(err, "API response %q was missing fields", string(body))
return
}
pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData)
comment = *event.Comment.Text
return
}
func (e *EventParser) parseCommonBitbucketServerEventData(event bitbucketserver.CommonEventData) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
var prState models.PullRequestState
switch *event.PullRequest.State {
case "OPEN":
prState = models.OpenPullState
case "MERGED":
prState = models.ClosedPullState
case "DECLINED":
prState = models.ClosedPullState
default:
err = fmt.Errorf("unable to determine pull request state from %qthis is a bug", *event.PullRequest.State)
return
}
headRepoSlug := *event.PullRequest.FromRef.Repository.Slug
headRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.FromRef.Repository.Project.Name, headRepoSlug)
headRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.FromRef.Repository.Project.Key), headRepoSlug)
headRepo, err = models.NewRepo(
models.BitbucketServer,
headRepoFullname,
headRepoCloneURL,
e.BitbucketUser,
e.BitbucketToken)
if err != nil {
return
}
baseRepoSlug := *event.PullRequest.ToRef.Repository.Slug
baseRepoFullname := fmt.Sprintf("%s/%s", *event.PullRequest.ToRef.Repository.Project.Name, baseRepoSlug)
baseRepoCloneURL := fmt.Sprintf("%s/scm/%s/%s.git", e.BitbucketServerURL, strings.ToLower(*event.PullRequest.ToRef.Repository.Project.Key), baseRepoSlug)
baseRepo, err = models.NewRepo(
models.BitbucketServer,
baseRepoFullname,
baseRepoCloneURL,
e.BitbucketUser,
e.BitbucketToken)
if err != nil {
return
}
pull = models.PullRequest{
Num: *event.PullRequest.ID,
HeadCommit: *event.PullRequest.FromRef.LatestCommit,
URL: fmt.Sprintf("%s/projects/%s/repos/%s/pull-requests/%d", e.BitbucketServerURL, *event.PullRequest.ToRef.Repository.Project.Key, *event.PullRequest.ToRef.Repository.Slug, *event.PullRequest.ID),
HeadBranch: *event.PullRequest.FromRef.DisplayID,
BaseBranch: *event.PullRequest.ToRef.DisplayID,
Author: *event.Actor.Username,
State: prState,
BaseRepo: baseRepo,
}
user = models.User{
Username: *event.Actor.Username,
}
return
}
// ParseBitbucketServerPullEvent parses a pull request event from Bitbucket
// Server.
// See EventParsing for return value docs.
func (e *EventParser) ParseBitbucketServerPullEvent(body []byte) (pull models.PullRequest, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
var event bitbucketserver.PullRequestEvent
if err = json.Unmarshal(body, &event); err != nil {
err = errors.Wrap(err, "parsing json")
return
}
if err = validator.New().Struct(event); err != nil {
err = errors.Wrapf(err, "API response %q was missing fields", string(body))
return
}
pull, baseRepo, headRepo, user, err = e.parseCommonBitbucketServerEventData(event.CommonEventData)
return
}
// ParseAzureDevopsPullEvent parses Azure DevOps pull request events.
// See EventParsing for return value docs.
func (e *EventParser) ParseAzureDevopsPullEvent(event azuredevops.Event) (pull models.PullRequest, pullEventType models.PullRequestEventType, baseRepo models.Repo, headRepo models.Repo, user models.User, err error) {
pullResource, ok := event.Resource.(*azuredevops.GitPullRequest)
if !ok {
err = errors.New("failed to type assert event.Resource")
return
}
pull, baseRepo, headRepo, err = e.ParseAzureDevopsPull(pullResource)
if err != nil {
return
}
createdBy := pullResource.GetCreatedBy()
if createdBy == nil {
err = errors.New("CreatedBy is null")
return
}
senderUsername := createdBy.GetUniqueName()
if senderUsername == "" {
err = errors.New("CreatedBy.UniqueName is null")
return
}
switch event.EventType {
case "git.pullrequest.created":
pullEventType = models.OpenedPullEvent
case "git.pullrequest.updated":
pullEventType = models.UpdatedPullEvent
if pull.State == models.ClosedPullState {
pullEventType = models.ClosedPullEvent
}
default:
pullEventType = models.OtherPullEvent
}
user = models.User{Username: senderUsername}
return
}
// ParseAzureDevopsPull parses the response from the Azure DevOps API endpoint (not
// from a webhook) that returns a pull request.
// See EventParsing for return value docs.
func (e *EventParser) ParseAzureDevopsPull(pull *azuredevops.GitPullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) {
commit := pull.LastMergeSourceCommit.GetCommitID()
if commit == "" {
err = errors.New("lastMergeSourceCommit.commitID is null")
return
}
url := pull.GetURL()
if url == "" {
err = errors.New("url is null")
return
}
headBranch := pull.GetSourceRefName()
if headBranch == "" {
err = errors.New("sourceRefName (branch name) is null")
return
}
baseBranch := pull.GetTargetRefName()
if baseBranch == "" {
err = errors.New("targetRefName (branch name) is null")
return
}
num := pull.GetPullRequestID()
if num == 0 {
err = errors.New("pullRequestId is null")
return
}
createdBy := pull.GetCreatedBy()
if createdBy == nil {
err = errors.New("CreatedBy is null")
return
}
authorUsername := createdBy.GetUniqueName()
if authorUsername == "" {
err = errors.New("CreatedBy.UniqueName is null")
return
}
baseRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository())
if err != nil {
return
}
headRepo, err = e.ParseAzureDevopsRepo(pull.GetRepository())
if err != nil {
return
}
pullState := models.ClosedPullState
if *pull.Status == azuredevops.PullActive.String() {
pullState = models.OpenPullState
}
pullModel = models.PullRequest{
Author: authorUsername,
// Change webhook refs from "refs/heads/<branch>" to "<branch>"
HeadBranch: strings.Replace(headBranch, "refs/heads/", "", 1),
HeadCommit: commit,
URL: url,
Num: num,
State: pullState,
BaseRepo: baseRepo,
BaseBranch: strings.Replace(baseBranch, "refs/heads/", "", 1),
}
return
}
// ParseAzureDevopsRepo parses the response from the Azure DevOps API endpoint that
// returns a repo into the Atlantis model.
// If the event payload doesn't contain a parent repository reference, extract the owner
// name from the URL. The URL will match one of two different formats:
//
// https://runatlantis.visualstudio.com/project/_git/repo
// https://dev.azure.com/runatlantis/project/_git/repo
//
// See EventParsing for return value docs.
func (e *EventParser) ParseAzureDevopsRepo(adRepo *azuredevops.GitRepository) (models.Repo, error) {
teamProject := adRepo.GetProject()
parent := adRepo.GetParentRepository()
owner := ""
uri, err := url.Parse(adRepo.GetWebURL())
if err != nil {
return models.Repo{}, err
}
if parent != nil {
owner = parent.GetName()
} else {
if strings.Contains(uri.Host, "visualstudio.com") {
owner = strings.Split(uri.Host, ".")[0]
} else {
owner = strings.Split(uri.Path, "/")[1]
}
}
// Construct our own clone URL so we always get the new dev.azure.com
// hostname for now.
// https://docs.microsoft.com/en-us/azure/devops/release-notes/2018/sep-10-azure-devops-launch#switch-existing-organizations-to-use-the-new-domain-name-url
project := teamProject.GetName()
repo := adRepo.GetName()
host := uri.Host
if host == "" {
host = "dev.azure.com"
}
cloneURL := ""
// If statement allows compatibility with legacy Visual Studio Team Foundation Services URLs.
// Else statement covers Azure DevOps Services URLs
if strings.Contains(host, "visualstudio.com") {
cloneURL = fmt.Sprintf("https://%s/%s/_git/%s", host, project, repo)
} else {
cloneURL = fmt.Sprintf("https://%s/%s/%s/_git/%s", host, owner, project, repo)
}
fmt.Println("%", cloneURL)
fullName := fmt.Sprintf("%s/%s/%s", owner, project, repo)
return models.NewRepo(models.AzureDevops, fullName, cloneURL, e.AzureDevopsUser, e.AzureDevopsToken)
}
func (e *EventParser) ParseGiteaPullRequestEvent(event giteasdk.PullRequest) (models.PullRequest, models.PullRequestEventType, models.Repo, models.Repo, models.User, error) {
var pullEventType models.PullRequestEventType
// Determine the event type based on the state of the pull request and whether it's merged.
switch {
case event.State == giteasdk.StateOpen:
pullEventType = models.OpenedPullEvent
case event.HasMerged:
pullEventType = models.ClosedPullEvent
default:
pullEventType = models.OtherPullEvent
}
// Parse the base repository.
baseRepo, err := models.NewRepo(
models.Gitea,
event.Base.Repository.FullName,
event.Base.Repository.CloneURL,
e.GiteaUser,
e.GiteaToken,
)
if err != nil {
return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err
}
// Parse the head repository.
headRepo, err := models.NewRepo(
models.Gitea,
event.Head.Repository.FullName,
event.Head.Repository.CloneURL,
e.GiteaUser,
e.GiteaToken,
)
if err != nil {
return models.PullRequest{}, models.OtherPullEvent, models.Repo{}, models.Repo{}, models.User{}, err
}
// Construct the pull request model.
pull := models.PullRequest{
Num: int(event.Index),
URL: event.HTMLURL,
HeadCommit: event.Head.Sha,
HeadBranch: (*event.Head).Ref,
BaseBranch: event.Base.Ref,
Author: event.Poster.UserName,
BaseRepo: baseRepo,
}
// Parse the user who made the pull request.
user := models.User{
Username: event.Poster.UserName,
}
return pull, pullEventType, baseRepo, headRepo, user, nil
}
// ParseGiteaPull parses the response from the Gitea API endpoint (not
// from a webhook) that returns a pull request.
// See EventParsing for return value docs.
func (e *EventParser) ParseGiteaPull(pull *giteasdk.PullRequest) (pullModel models.PullRequest, baseRepo models.Repo, headRepo models.Repo, err error) {
commit := pull.Head.Sha
if commit == "" {
err = errors.New("head.sha is null")
return
}
url := pull.HTMLURL
if url == "" {
err = errors.New("html_url is null")
return
}
headBranch := pull.Head.Ref
if headBranch == "" {
err = errors.New("head.ref is null")
return
}
baseBranch := pull.Base.Ref
if baseBranch == "" {
err = errors.New("base.ref is null")
return
}
authorUsername := pull.Poster.UserName
if authorUsername == "" {
err = errors.New("user.login is null")
return
}
num := pull.Index
if num == 0 {
err = errors.New("number is null")
return
}
baseRepo, err = e.ParseGiteaRepo(*pull.Base.Repository)
if err != nil {
return
}
headRepo, err = e.ParseGiteaRepo(*pull.Head.Repository)
if err != nil {
return
}
pullState := models.ClosedPullState
if pull.State == "open" {
pullState = models.OpenPullState
}
pullModel = models.PullRequest{
Author: authorUsername,
HeadBranch: headBranch,
HeadCommit: commit,
URL: url,
Num: int(num),
State: pullState,
BaseRepo: baseRepo,
BaseBranch: baseBranch,
}
return
}