Files
atlantis/server/events/vcs/github_client_test.go
Torben Tretau 62271955de fix(github): Mergeable requirement for fork PRs (#3620)
While using a PR from a fork and the "Github allow mergeable bypass apply" flag, the mergeable checks were run with the wrong owner in the request, leading to 404. By choosing the owner from the head repo data it should work both, for fork PRs and in-repo PRs.

Co-authored-by: Dylan Page <dylan.page@autodesk.com>
2023-08-01 02:25:31 +00:00

1369 lines
38 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_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
})
}
}