Files
atlantis/server/events/vcs/github_client_test.go
Ghais Zaher 80ecc38273 feat: disable autoplan label (#3649)
* feat: disable autoplan label

* documentation

* revert unrelated change

* fix property

* gitlab and github

* dd more test

* small fixes

* add tests for github and gitlab clients

* fix: remove unrelated comments

* fmt

---------

Co-authored-by: PePe Amengual <jose.amengual@gmail.com>
2023-09-25 13:43:35 -04:00

1584 lines
45 KiB
Go

package vcs_test
import (
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
"github.com/shurcooL/githubv4"
)
// GetModifiedFiles should make multiple requests if more than one page
// and concat results.
func TestGithubClient_GetModifiedFiles(t *testing.T) {
logger := logging.NewNoopLogger(t)
respTemplate := `[
{
"sha": "bbcd538c8e72b8c175046e27cc8f907076331401",
"filename": "%s",
"status": "added",
"additions": 103,
"deletions": 21,
"changes": 124,
"blob_url": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt",
"raw_url": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt",
"contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e",
"patch": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"
}
]`
firstResp := fmt.Sprintf(respTemplate, "file1.txt")
secondResp := fmt.Sprintf(respTemplate, "file2.txt")
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
// The first request should hit this URL.
case "/api/v3/repos/owner/repo/pulls/1/files?per_page=300":
// We write a header that means there's an additional page.
w.Header().Add("Link", `<https://api.github.com/resource?page=2>; rel="next",
<https://api.github.com/resource?page=2>; rel="last"`)
w.Write([]byte(firstResp)) // nolint: errcheck
return
// The second should hit this URL.
case "/api/v3/repos/owner/repo/pulls/1/files?page=2&per_page=300":
w.Write([]byte(secondResp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
Ok(t, err)
defer disableSSLVerification()()
files, err := client.GetModifiedFiles(models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
})
Ok(t, err)
Equals(t, []string{"file1.txt", "file2.txt"}, files)
}
// GetModifiedFiles should include the source and destination of a moved
// file.
func TestGithubClient_GetModifiedFilesMovedFile(t *testing.T) {
resp := `[
{
"sha": "bbcd538c8e72b8c175046e27cc8f907076331401",
"filename": "new/filename.txt",
"previous_filename": "previous/filename.txt",
"status": "renamed",
"additions": 103,
"deletions": 21,
"changes": 124,
"blob_url": "https://github.com/octocat/Hello-World/blob/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt",
"raw_url": "https://github.com/octocat/Hello-World/raw/6dcb09b5b57875f334f61aebed695e2e4193db5e/file1.txt",
"contents_url": "https://api.github.com/repos/octocat/Hello-World/contents/file1.txt?ref=6dcb09b5b57875f334f61aebed695e2e4193db5e",
"patch": "@@ -132,7 +132,7 @@ module Test @@ -1000,7 +1000,7 @@ module Test"
}
]`
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
// The first request should hit this URL.
case "/api/v3/repos/owner/repo/pulls/1/files?per_page=300":
w.Write([]byte(resp)) // nolint: errcheck
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
files, err := client.GetModifiedFiles(models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
})
Ok(t, err)
Equals(t, []string{"new/filename.txt", "previous/filename.txt"}, files)
}
func TestGithubClient_PaginatesComments(t *testing.T) {
calls := 0
issueResps := []string{
`[
{"node_id": "1", "body": "asd\nplan\nasd", "user": {"login": "someone-else"}},
{"node_id": "2", "body": "asd plan\nasd", "user": {"login": "user"}}
]`,
`[
{"node_id": "3", "body": "asd", "user": {"login": "someone-else"}},
{"node_id": "4", "body": "asdasd", "user": {"login": "someone-else"}}
]`,
`[
{"node_id": "5", "body": "asd plan", "user": {"login": "someone-else"}},
{"node_id": "6", "body": "asd\nplan", "user": {"login": "user"}}
]`,
`[
{"node_id": "7", "body": "asd", "user": {"login": "user"}},
{"node_id": "8", "body": "asd plan \n asd", "user": {"login": "user"}}
]`,
}
minimizeResp := "{}"
type graphQLCall struct {
Variables struct {
Input githubv4.MinimizeCommentInput `json:"input"`
} `json:"variables"`
}
gotMinimizeCalls := make([]graphQLCall, 0, 2)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.RequestURI {
case "POST /api/graphql":
defer r.Body.Close() // nolint: errcheck
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
call := graphQLCall{}
err = json.Unmarshal(body, &call)
if err != nil {
t.Errorf("parse body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
gotMinimizeCalls = append(gotMinimizeCalls, call)
w.Write([]byte(minimizeResp)) // nolint: errcheck
return
default:
if r.Method != "GET" || !strings.HasPrefix(r.RequestURI, "/api/v3/repos/owner/repo/issues/123/comments") {
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
if (calls + 1) < len(issueResps) {
w.Header().Add(
"Link",
fmt.Sprintf(
`<http://%s/api/v3/repos/owner/repo/issues/123/comments?page=%d&per_page=100>; rel="next"`,
r.Host,
calls+1,
),
)
}
w.Write([]byte(issueResps[calls])) // nolint: errcheck
calls++
}
}),
)
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
err = client.HidePrevCommandComments(
models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Hostname: "github.com",
Type: models.Github,
},
},
123,
command.Plan.TitleString(),
)
Ok(t, err)
Equals(t, 2, len(gotMinimizeCalls))
Equals(t, "2", gotMinimizeCalls[0].Variables.Input.SubjectID)
Equals(t, "8", gotMinimizeCalls[1].Variables.Input.SubjectID)
Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[0].Variables.Input.Classifier)
Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[1].Variables.Input.Classifier)
}
func TestGithubClient_HideOldComments(t *testing.T) {
// Only comment 6 should be minimized, because it's by the same Atlantis bot user
// and it has "plan" in the first line of the comment body.
issueResp := `[
{"node_id": "1", "body": "asd\nplan\nasd", "user": {"login": "someone-else"}},
{"node_id": "2", "body": "asd plan\nasd", "user": {"login": "someone-else"}},
{"node_id": "3", "body": "asdasdasd\nasdasdasd", "user": {"login": "someone-else"}},
{"node_id": "4", "body": "asdasdasd\nasdasdasd", "user": {"login": "user"}},
{"node_id": "5", "body": "asd\nplan\nasd", "user": {"login": "user"}},
{"node_id": "6", "body": "asd plan\nasd", "user": {"login": "user"}},
{"node_id": "7", "body": "asdasdasd", "user": {"login": "user"}},
{"node_id": "8", "body": "asd plan\nasd", "user": {"login": "user"}},
{"node_id": "9", "body": "Continued Plan from previous comment\nasd", "user": {"login": "user"}}
]`
minimizeResp := "{}"
type graphQLCall struct {
Variables struct {
Input githubv4.MinimizeCommentInput `json:"input"`
} `json:"variables"`
}
gotMinimizeCalls := make([]graphQLCall, 0, 1)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.RequestURI {
// This gets the pull request's comments.
case "GET /api/v3/repos/owner/repo/issues/123/comments?direction=asc&sort=created":
w.Write([]byte(issueResp)) // nolint: errcheck
return
case "POST /api/graphql":
defer r.Body.Close() // nolint: errcheck
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
call := graphQLCall{}
err = json.Unmarshal(body, &call)
if err != nil {
t.Errorf("parse body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
gotMinimizeCalls = append(gotMinimizeCalls, call)
w.Write([]byte(minimizeResp)) // nolint: errcheck
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}),
)
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
err = client.HidePrevCommandComments(
models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Hostname: "github.com",
Type: models.Github,
},
},
123,
command.Plan.TitleString(),
)
Ok(t, err)
Equals(t, 3, len(gotMinimizeCalls))
Equals(t, "6", gotMinimizeCalls[0].Variables.Input.SubjectID)
Equals(t, "9", gotMinimizeCalls[2].Variables.Input.SubjectID)
Equals(t, githubv4.ReportedContentClassifiersOutdated, gotMinimizeCalls[0].Variables.Input.Classifier)
}
func TestGithubClient_UpdateStatus(t *testing.T) {
cases := []struct {
status models.CommitStatus
expState string
}{
{
models.PendingCommitStatus,
"pending",
},
{
models.SuccessCommitStatus,
"success",
},
{
models.FailedCommitStatus,
"failure",
},
}
for _, c := range cases {
t.Run(c.status.String(), func(t *testing.T) {
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/owner/repo/statuses/":
body, err := io.ReadAll(r.Body)
Ok(t, err)
exp := fmt.Sprintf(`{"state":"%s","target_url":"https://google.com","description":"description","context":"src"}%s`, c.expState, "\n")
Equals(t, exp, string(body))
defer r.Body.Close() // nolint: errcheck
w.WriteHeader(http.StatusOK)
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
err = client.UpdateStatus(models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
}, c.status, "src", "description", "https://google.com")
Ok(t, err)
})
}
}
func TestGithubClient_PullIsApproved(t *testing.T) {
respTemplate := `[
{
"id": %d,
"node_id": "MDE3OlB1bGxSZXF1ZXN0UmV2aWV3ODA=",
"user": {
"login": "octocat",
"id": 1,
"node_id": "MDQ6VXNlcjE=",
"avatar_url": "https://github.com/images/error/octocat_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/users/octocat",
"html_url": "https://github.com/octocat",
"followers_url": "https://api.github.com/users/octocat/followers",
"following_url": "https://api.github.com/users/octocat/following{/other_user}",
"gists_url": "https://api.github.com/users/octocat/gists{/gist_id}",
"starred_url": "https://api.github.com/users/octocat/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/octocat/subscriptions",
"organizations_url": "https://api.github.com/users/octocat/orgs",
"repos_url": "https://api.github.com/users/octocat/repos",
"events_url": "https://api.github.com/users/octocat/events{/privacy}",
"received_events_url": "https://api.github.com/users/octocat/received_events",
"type": "User",
"site_admin": false
},
"body": "Here is the body for the review.",
"commit_id": "ecdd80bb57125d7ba9641ffaa4d7d2c19d3f3091",
"state": "CHANGES_REQUESTED",
"html_url": "https://github.com/octocat/Hello-World/pull/12#pullrequestreview-%d",
"pull_request_url": "https://api.github.com/repos/octocat/Hello-World/pulls/12",
"_links": {
"html": {
"href": "https://github.com/octocat/Hello-World/pull/12#pullrequestreview-%d"
},
"pull_request": {
"href": "https://api.github.com/repos/octocat/Hello-World/pulls/12"
}
}
}
]`
firstResp := fmt.Sprintf(respTemplate, 80, 80, 80)
secondResp := fmt.Sprintf(respTemplate, 81, 81, 81)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
// The first request should hit this URL.
case "/api/v3/repos/owner/repo/pulls/1/reviews?per_page=300":
// We write a header that means there's an additional page.
w.Header().Add("Link", `<https://api.github.com/resource?page=2>; rel="next",
<https://api.github.com/resource?page=2>; rel="last"`)
w.Write([]byte(firstResp)) // nolint: errcheck
return
// The second should hit this URL.
case "/api/v3/repos/owner/repo/pulls/1/reviews?page=2&per_page=300":
w.Write([]byte(secondResp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
approvalStatus, err := client.PullIsApproved(models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
})
Ok(t, err)
Equals(t, false, approvalStatus.IsApproved)
}
func TestGithubClient_PullIsMergeable(t *testing.T) {
vcsStatusName := "atlantis-test"
cases := []struct {
state string
expMergeable bool
}{
{
"dirty",
false,
},
{
"unknown",
false,
},
{
"blocked",
false,
},
{
"behind",
false,
},
{
"random",
false,
},
{
"unstable",
true,
},
{
"has_hooks",
true,
},
{
"clean",
true,
},
{
"",
false,
},
}
// Use a real GitHub json response and edit the mergeable_state field.
jsBytes, err := os.ReadFile("testdata/github-pull-request.json")
Ok(t, err)
prJSON := string(jsBytes)
// Status Check Response
jsBytes, err = os.ReadFile("testdata/github-commit-status-full.json")
Ok(t, err)
commitJSON := string(jsBytes)
//reviewDecision Response
reviewDecision := `{
"data": {
"repository": {
"pullRequest": {
"reviewDecision": "REVIEW_REQUIRED"
}
}
}
}`
for _, c := range cases {
t.Run(c.state, func(t *testing.T) {
response := strings.Replace(prJSON,
`"mergeable_state": "clean"`,
fmt.Sprintf(`"mergeable_state": "%s"`, c.state),
1,
)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/owner/repo/pulls/1":
w.Write([]byte(response)) // nolint: errcheck
return
case "/api/v3/repos/owner/repo/pulls/1/reviews?per_page=300":
w.Write([]byte("[]")) // nolint: errcheck
return
case "/api/v3/repos/owner/repo/commits/new-topic/status":
w.Write([]byte(commitJSON)) // nolint: errcheck
case "/api/graphql":
w.Write([]byte(reviewDecision)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
actMergeable, err := client.PullIsMergeable(models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
}, vcsStatusName)
Ok(t, err)
Equals(t, c.expMergeable, actMergeable)
})
}
}
func TestGithubClient_PullIsMergeableWithAllowMergeableBypassApply(t *testing.T) {
vcsStatusName := "atlantis"
cases := []struct {
state string
reviewDecision string
expMergeable bool
}{
{
"dirty",
`"REVIEW_REQUIRED"`,
false,
},
{
"unknown",
`"REVIEW_REQUIRED"`,
false,
},
{
"blocked",
`"REVIEW_REQUIRED"`,
false,
},
{
"blocked",
`"APPROVED"`,
true,
},
{
"blocked",
"null",
true,
},
{
"behind",
`"REVIEW_REQUIRED"`,
false,
},
{
"random",
`"REVIEW_REQUIRED"`,
false,
},
{
"unstable",
`"REVIEW_REQUIRED"`,
true,
},
{
"has_hooks",
`"APPROVED"`,
true,
},
{
"clean",
`"APPROVED"`,
true,
},
{
"",
`"APPROVED"`,
false,
},
}
// Use a real GitHub json response and edit the mergeable_state field.
jsBytes, err := os.ReadFile("testdata/github-pull-request.json")
Ok(t, err)
prJSON := string(jsBytes)
// Status Check Response
jsBytes, err = os.ReadFile("testdata/github-commit-status-full.json")
Ok(t, err)
commitJSON := string(jsBytes)
// Branch protection Response
jsBytes, err = os.ReadFile("testdata/github-branch-protection-required-checks.json")
Ok(t, err)
branchProtectionJSON := string(jsBytes)
// List check suites Response
jsBytes, err = os.ReadFile("testdata/github-commit-check-suites.json")
Ok(t, err)
checkSuites := string(jsBytes)
for _, c := range cases {
t.Run(c.state, func(t *testing.T) {
response := strings.Replace(prJSON,
`"mergeable_state": "clean"`,
fmt.Sprintf(`"mergeable_state": "%s"`, c.state),
1,
)
// reviewDecision Response
reviewDecision := fmt.Sprintf(`{
"data": {
"repository": {
"pullRequest": {
"reviewDecision": %s
}
}
}
}`, c.reviewDecision)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/octocat/repo/pulls/1":
w.Write([]byte(response)) // nolint: errcheck
return
case "/api/v3/repos/octocat/repo/pulls/1/reviews?per_page=300":
w.Write([]byte("[]")) // nolint: errcheck
return
case "/api/v3/repos/octocat/repo/commits/new-topic/status":
w.Write([]byte(commitJSON)) // nolint: errcheck
case "/api/graphql":
w.Write([]byte(reviewDecision)) // nolint: errcheck
case "/api/v3/repos/octocat/repo/branches/main/protection":
w.Write([]byte(branchProtectionJSON)) // nolint: errcheck
case "/api/v3/repos/octocat/repo/commits/new-topic/check-suites":
w.Write([]byte(checkSuites)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
actMergeable, err := client.PullIsMergeable(models.Repo{
FullName: "octocat/repo",
Owner: "octocat",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
}, vcsStatusName)
Ok(t, err)
Equals(t, c.expMergeable, actMergeable)
})
}
}
func TestGithubClient_PullIsMergeableWithAllowMergeableBypassApplyButWithNoBranchProtectionChecks(t *testing.T) {
vcsStatusName := "atlantis"
cases := []struct {
state string
reviewDecision string
expMergeable bool
}{
{
"blocked",
`"REVIEW_REQUIRED"`,
false,
},
}
// Use a real GitHub json response and edit the mergeable_state field.
jsBytes, err := os.ReadFile("testdata/github-pull-request.json")
Ok(t, err)
prJSON := string(jsBytes)
// Status Check Response
jsBytes, err = os.ReadFile("testdata/github-commit-status-full.json")
Ok(t, err)
commitJSON := string(jsBytes)
// Branch protection Response
jsBytes, err = os.ReadFile("testdata/github-branch-protection-no-required-checks.json")
Ok(t, err)
branchProtectionJSON := string(jsBytes)
// List check suites Response
jsBytes, err = os.ReadFile("testdata/github-commit-check-suites-completed.json")
Ok(t, err)
checkSuites := string(jsBytes)
// List check runs in a check suite
jsBytes, err = os.ReadFile("testdata/github-commit-check-suites-check-runs-completed.json")
Ok(t, err)
checkRuns := string(jsBytes)
for _, c := range cases {
t.Run(c.state, func(t *testing.T) {
response := strings.Replace(prJSON,
`"mergeable_state": "clean"`,
fmt.Sprintf(`"mergeable_state": "%s"`, c.state),
1,
)
// reviewDecision Response
reviewDecision := fmt.Sprintf(`{
"data": {
"repository": {
"pullRequest": {
"reviewDecision": %s
}
}
}
}`, c.reviewDecision)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/octocat/Hello-World/pulls/1":
w.Write([]byte(response)) // nolint: errcheck
return
case "/api/v3/repos/octocat/Hello-World/pulls/1/reviews?per_page=300":
w.Write([]byte("[]")) // nolint: errcheck
return
case "/api/v3/repos/octocat/Hello-World/commits/new-topic/status":
w.Write([]byte(commitJSON)) // nolint: errcheck
case "/api/graphql":
w.Write([]byte(reviewDecision)) // nolint: errcheck
case "/api/v3/repos/octocat/Hello-World/branches/main/protection":
w.Write([]byte(branchProtectionJSON)) // nolint: errcheck
case "/api/v3/repos/octocat/Hello-World/commits/new-topic/check-suites":
w.Write([]byte(checkSuites)) // nolint: errcheck
case "/api/v3/repos/octocat/Hello-World/check-suites/1234567890/check-runs":
w.Write([]byte(checkRuns)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{AllowMergeableBypassApply: true}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
actMergeable, err := client.PullIsMergeable(models.Repo{
FullName: "octocat/Hello-World",
Owner: "octocat",
Name: "Hello-World",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}, models.PullRequest{
Num: 1,
}, vcsStatusName)
Ok(t, err)
Equals(t, c.expMergeable, actMergeable)
})
}
}
func TestGithubClient_MergePullHandlesError(t *testing.T) {
cases := []struct {
code int
message string
merged string
expErr string
}{
{
code: 200,
message: "Pull Request successfully merged",
merged: "true",
expErr: "",
},
{
code: 405,
message: "Pull Request is not mergeable",
expErr: "405 Pull Request is not mergeable []",
},
{
code: 409,
message: "Head branch was modified. Review and try the merge again.",
expErr: "409 Head branch was modified. Review and try the merge again. []",
},
}
jsBytes, err := os.ReadFile("testdata/github-repo.json")
Ok(t, err)
for _, c := range cases {
t.Run(c.message, func(t *testing.T) {
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/owner/repo":
w.Write(jsBytes) // nolint: errcheck
return
case "/api/v3/repos/owner/repo/pulls/1/merge":
body, err := io.ReadAll(r.Body)
Ok(t, err)
exp := "{\"merge_method\":\"merge\"}\n"
Equals(t, exp, string(body))
var resp string
if c.code == 200 {
resp = fmt.Sprintf(`{"message":"%s","merged":%s}%s`, c.message, c.merged, "\n")
} else {
resp = fmt.Sprintf(`{"message":"%s"}%s`, c.message, "\n")
}
defer r.Body.Close() // nolint: errcheck
w.WriteHeader(c.code)
w.Write([]byte(resp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
err = client.MergePull(
models.PullRequest{
BaseRepo: models.Repo{
FullName: "owner/repo",
Owner: "owner",
Name: "repo",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
},
Num: 1,
}, models.PullRequestOptions{
DeleteSourceBranchOnMerge: false,
})
if c.expErr == "" {
Ok(t, err)
} else {
ErrContains(t, c.expErr, err)
}
})
}
}
// Test that if the pull request only allows a certain merge method that we
// use that method
func TestGithubClient_MergePullCorrectMethod(t *testing.T) {
cases := map[string]struct {
allowMerge bool
allowRebase bool
allowSquash bool
expMethod string
}{
"all true": {
allowMerge: true,
allowRebase: true,
allowSquash: true,
expMethod: "merge",
},
"all false (edge case)": {
allowMerge: false,
allowRebase: false,
allowSquash: false,
expMethod: "merge",
},
"merge: false rebase: true squash: true": {
allowMerge: false,
allowRebase: true,
allowSquash: true,
expMethod: "rebase",
},
"merge: false rebase: false squash: true": {
allowMerge: false,
allowRebase: false,
allowSquash: true,
expMethod: "squash",
},
"merge: false rebase: true squash: false": {
allowMerge: false,
allowRebase: true,
allowSquash: false,
expMethod: "rebase",
},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
// Modify response.
jsBytes, err := os.ReadFile("testdata/github-repo.json")
Ok(t, err)
resp := string(jsBytes)
resp = strings.Replace(resp,
`"allow_squash_merge": true`,
fmt.Sprintf(`"allow_squash_merge": %t`, c.allowSquash),
-1)
resp = strings.Replace(resp,
`"allow_merge_commit": true`,
fmt.Sprintf(`"allow_merge_commit": %t`, c.allowMerge),
-1)
resp = strings.Replace(resp,
`"allow_rebase_merge": true`,
fmt.Sprintf(`"allow_rebase_merge": %t`, c.allowRebase),
-1)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/runatlantis/atlantis":
w.Write([]byte(resp)) // nolint: errcheck
return
case "/api/v3/repos/runatlantis/atlantis/pulls/1/merge":
body, err := io.ReadAll(r.Body)
Ok(t, err)
defer r.Body.Close() // nolint: errcheck
type bodyJSON struct {
MergeMethod string `json:"merge_method"`
}
expBody := bodyJSON{
MergeMethod: c.expMethod,
}
expBytes, err := json.Marshal(expBody)
Ok(t, err)
Equals(t, string(expBytes)+"\n", string(body))
resp := `{"sha":"6dcb09b5b57875f334f61aebed695e2e4193db5e","merged":true,"message":"Pull Request successfully merged"}`
w.Write([]byte(resp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
err = client.MergePull(
models.PullRequest{
BaseRepo: models.Repo{
FullName: "runatlantis/atlantis",
Owner: "runatlantis",
Name: "atlantis",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
},
Num: 1,
}, models.PullRequestOptions{
DeleteSourceBranchOnMerge: false,
})
Ok(t, err)
})
}
}
func TestGithubClient_MarkdownPullLink(t *testing.T) {
client, err := vcs.NewGithubClient("hostname", &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
pull := models.PullRequest{Num: 1}
s, _ := client.MarkdownPullLink(pull)
exp := "#1"
Equals(t, exp, s)
}
// disableSSLVerification disables ssl verification for the global http client
// and returns a function to be called in a defer that will re-enable it.
func disableSSLVerification() func() {
orig := http.DefaultTransport.(*http.Transport).TLSClientConfig
// nolint: gosec
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
return func() {
http.DefaultTransport.(*http.Transport).TLSClientConfig = orig
}
}
func TestGithubClient_SplitComments(t *testing.T) {
type githubComment struct {
Body string `json:"body"`
}
githubComments := make([]githubComment, 0, 1)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.RequestURI {
case "POST /api/v3/repos/runatlantis/atlantis/issues/1/comments":
defer r.Body.Close() // nolint: errcheck
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
requestBody := githubComment{}
err = json.Unmarshal(body, &requestBody)
if err != nil {
t.Errorf("parse body error: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
githubComments = append(githubComments, requestBody)
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
pull := models.PullRequest{Num: 1}
repo := models.Repo{
FullName: "runatlantis/atlantis",
Owner: "runatlantis",
Name: "atlantis",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}
// create an extra long string
comment := strings.Repeat("a", 65537)
err = client.CreateComment(repo, pull.Num, comment, command.Plan.String())
Ok(t, err)
err = client.CreateComment(repo, pull.Num, comment, "")
Ok(t, err)
body := strings.Split(githubComments[1].Body, "\n")
firstSplit := strings.ToLower(body[0])
body = strings.Split(githubComments[3].Body, "\n")
secondSplit := strings.ToLower(body[0])
Equals(t, 4, len(githubComments))
Assert(t, strings.Contains(firstSplit, command.Plan.String()), fmt.Sprintf("comment should contain the command name but was %q", firstSplit))
Assert(t, strings.Contains(secondSplit, "continued from previous comment"), fmt.Sprintf("comment should contain no reference to the command name but was %q", secondSplit))
}
// Test that we retry the get pull request call if it 404s.
func TestGithubClient_Retry404(t *testing.T) {
var numCalls = 0
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.RequestURI {
case "GET /api/v3/repos/runatlantis/atlantis/pulls/1":
defer r.Body.Close() // nolint: errcheck
numCalls++
if numCalls < 3 {
w.WriteHeader(404)
} else {
w.WriteHeader(200)
}
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
repo := models.Repo{
FullName: "runatlantis/atlantis",
Owner: "runatlantis",
Name: "atlantis",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}
_, err = client.GetPullRequest(repo, 1)
Ok(t, err)
Equals(t, 3, numCalls)
}
// Test that we retry the get pull request files call if it 404s.
func TestGithubClient_Retry404Files(t *testing.T) {
var numCalls = 0
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method + " " + r.RequestURI {
case "GET /api/v3/repos/runatlantis/atlantis/pulls/1/files?per_page=300":
defer r.Body.Close() // nolint: errcheck
numCalls++
if numCalls < 3 {
w.WriteHeader(404)
} else {
w.WriteHeader(200)
}
return
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
repo := models.Repo{
FullName: "runatlantis/atlantis",
Owner: "runatlantis",
Name: "atlantis",
CloneURL: "",
SanitizedCloneURL: "",
VCSHost: models.VCSHost{
Type: models.Github,
Hostname: "github.com",
},
}
pr := models.PullRequest{Num: 1}
_, err = client.GetModifiedFiles(repo, pr)
Ok(t, err)
Equals(t, 3, numCalls)
}
// GetTeamNamesForUser returns a list of team names for a user.
func TestGithubClient_GetTeamNamesForUser(t *testing.T) {
logger := logging.NewNoopLogger(t)
// Mocked GraphQL response for two teams
resp := `{
"data":{
"organization": {
"teams":{
"edges":[
{"node":{"name": "Frontend Developers", "slug":"frontend-developers"}},
{"node":{"name": "Employees", "slug":"employees"}}
],
"pageInfo":{
"endCursor":"Y3Vyc29yOnYyOpHOAFMoLQ==",
"hasNextPage":false
}
}
}
}
}`
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/graphql":
w.Write([]byte(resp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
Ok(t, err)
defer disableSSLVerification()()
teams, err := client.GetTeamNamesForUser(models.Repo{
Owner: "testrepo",
}, models.User{
Username: "testuser",
})
Ok(t, err)
Equals(t, []string{"Frontend Developers", "frontend-developers", "Employees", "employees"}, teams)
}
func TestGithubClient_DiscardReviews(t *testing.T) {
type ResponseDef struct {
httpCode int
body string
}
type fields struct {
responses []ResponseDef
}
type args struct {
repo models.Repo
pull models.PullRequest
}
queryResponseSingleReview := `{
"data": {
"repository": {
"pullRequest": {
"reviewDecision": "APPROVED",
"reviews": {
"nodes": [
{
"id": "PRR_kwDOFxULt85HBb7A",
"submittedAt": "2022-11-23T12:28:30Z",
"author": {
"login": "atlantis-test"
}
}
]
}
}
}
}
}`
queryResponseMultipleReviews := `{
"data": {
"repository": {
"pullRequest": {
"reviewDecision": "APPROVED",
"reviews": {
"nodes": [
{
"id": "PRR_kwDOFxULt85HBb7A",
"submittedAt": "2022-11-23T12:28:30Z",
"author": {
"login": "atlantis-test"
}
},
{
"id": "PRR_kwDOFxULt85HBb7B",
"submittedAt": "2022-11-23T14:28:30Z",
"author": {
"login": "atlantis-test2"
}
}
]
}
}
}
}
}`
mutationResponseSingleReviewDismissal := `{
"data": {
"dismissPullRequestReview": {
"pullRequestReview": {
"id": "PRR_kwDOFxULt85HBb7A"
}
}
}
}`
tests := []struct {
name string
fields fields
args args
wantErr bool
}{
{
name: "return no error if dismissing a single approval",
fields: fields{
responses: []ResponseDef{
{
httpCode: 200,
body: queryResponseSingleReview,
},
{
httpCode: 200,
body: mutationResponseSingleReviewDismissal,
},
},
},
args: args{},
wantErr: false,
},
{
name: "return no error if dismissing multiple reviews",
fields: fields{
responses: []ResponseDef{
{
httpCode: 200,
body: queryResponseMultipleReviews,
},
{
httpCode: 200,
body: mutationResponseSingleReviewDismissal,
},
{
httpCode: 200,
body: mutationResponseSingleReviewDismissal,
},
},
},
args: args{},
wantErr: false,
},
{
name: "return error if query fails",
fields: fields{
responses: []ResponseDef{
{
httpCode: 500,
body: "",
},
},
},
args: args{},
wantErr: true,
},
{
name: "return error if mutating fails",
fields: fields{
responses: []ResponseDef{
{
httpCode: 200,
body: queryResponseSingleReview,
},
{
httpCode: 500,
body: "",
},
},
},
args: args{},
wantErr: true,
},
{
name: "return error if dismissing fails after already dismissing one",
fields: fields{
responses: []ResponseDef{
{
httpCode: 200,
body: queryResponseMultipleReviews,
},
{
httpCode: 200,
body: mutationResponseSingleReviewDismissal,
},
{
httpCode: 500,
body: "",
},
},
},
args: args{},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Mocked GraphQL response for two teams
responseIndex := 0
responseLength := len(tt.fields.responses)
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.RequestURI != "/api/graphql" {
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
Assert(t, responseIndex < responseLength, "requesting more responses than are defined")
response := tt.fields.responses[responseIndex]
responseIndex++
w.WriteHeader(response.httpCode)
w.Write([]byte(response.body)) // nolint: errcheck
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logging.NewNoopLogger(t))
Ok(t, err)
defer disableSSLVerification()()
if err := client.DiscardReviews(tt.args.repo, tt.args.pull); (err != nil) != tt.wantErr {
t.Errorf("DiscardReviews() error = %v, wantErr %v", err, tt.wantErr)
}
Equals(t, responseLength, responseIndex) // check if all defined requests have been used
})
}
}
func TestGithubClient_GetPullLabels(t *testing.T) {
logger := logging.NewNoopLogger(t)
resp := `{
"url": "https://api.github.com/repos/runatlantis/atlantis/pulls/1",
"id": 167530667,
"merge_commit_sha": "3fe6aa34bc25ac3720e639fcad41b428e83bdb37",
"labels": [
{
"id": 1303230720,
"node_id": "MDU6TGFiZWwxMzAzMjMwNzIw",
"url": "https://api.github.com/repos/runatlantis/atlantis/labels/docs",
"name": "docs",
"color": "d87165",
"default": false,
"description": "Documentation"
},
{
"id": 2552271640,
"node_id": "MDU6TGFiZWwyNTUyMjcxNjQw",
"url": "https://api.github.com/repos/runatlantis/atlantis/labels/go",
"name": "go",
"color": "16e2e2",
"default": false,
"description": "Pull requests that update Go code"
},
{
"id": 2696098981,
"node_id": "MDU6TGFiZWwyNjk2MDk4OTgx",
"url": "https://api.github.com/repos/runatlantis/atlantis/labels/needs%20tests",
"name": "needs tests",
"color": "FBB1DE",
"default": false,
"description": "Change requires tests"
},
{
"id": 4439792681,
"node_id": "LA_kwDOBy76Zc8AAAABCKHcKQ",
"url": "https://api.github.com/repos/runatlantis/atlantis/labels/work-in-progress",
"name": "work-in-progress",
"color": "B1E20A",
"default": false,
"description": ""
}
]
}`
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/runatlantis/atlantis/pulls/1":
w.Write([]byte(resp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
Ok(t, err)
defer disableSSLVerification()()
labels, err := client.GetPullLabels(models.Repo{
Owner: "runatlantis",
Name: "atlantis",
}, models.PullRequest{
Num: 1,
})
Ok(t, err)
Equals(t, []string{"docs", "go", "needs tests", "work-in-progress"}, labels)
}
func TestGithubClient_GetPullLabels_EmptyResponse(t *testing.T) {
logger := logging.NewNoopLogger(t)
resp := `{
"url": "https://api.github.com/repos/runatlantis/atlantis/pulls/1",
"id": 167530667,
"merge_commit_sha": "3fe6aa34bc25ac3720e639fcad41b428e83bdb37",
"labels": []
}`
testServer := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.RequestURI {
case "/api/v3/repos/runatlantis/atlantis/pulls/1":
w.Write([]byte(resp)) // nolint: errcheck
default:
t.Errorf("got unexpected request at %q", r.RequestURI)
http.Error(w, "not found", http.StatusNotFound)
return
}
}))
testServerURL, err := url.Parse(testServer.URL)
Ok(t, err)
client, err := vcs.NewGithubClient(testServerURL.Host, &vcs.GithubUserCredentials{"user", "pass"}, vcs.GithubConfig{}, logger)
Ok(t, err)
defer disableSSLVerification()()
labels, err := client.GetPullLabels(models.Repo{
Owner: "runatlantis",
Name: "atlantis",
}, models.PullRequest{
Num: 1,
})
Ok(t, err)
Equals(t, 0, len(labels))
}