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", `; rel="next", ; 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( `; 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", `; rel="next", ; 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)) }