Files
github--github-mcp-server/pkg/github/pullrequests_test.go
T
2026-07-13 12:37:57 +08:00

4381 lines
136 KiB
Go

package github
import (
"context"
"encoding/json"
"net/http"
"sync/atomic"
"testing"
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v89/github"
"github.com/google/jsonschema-go/jsonschema"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_GetPullRequest(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
// Setup mock PR for success case
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{
SHA: github.Ptr("abcd1234"),
Ref: github.Ptr("feature-branch"),
},
Base: &github.PullRequestBranch{
Ref: github.Ptr("main"),
},
Body: github.Ptr("This is a test PR"),
User: &github.User{
Login: github.Ptr("testuser"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedPR *github.PullRequest
expectedErrMsg string
}{
{
name: "successful PR fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedPR: mockPR,
},
{
name: "PR fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
},
}),
requestArgs: map[string]any{
"method": "get",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var returnedPR MinimalPullRequest
err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
require.NoError(t, err)
assert.Equal(t, tc.expectedPR.GetNumber(), returnedPR.Number)
assert.Equal(t, tc.expectedPR.GetTitle(), returnedPR.Title)
assert.Equal(t, tc.expectedPR.GetState(), returnedPR.State)
assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.HTMLURL)
})
}
}
func Test_UpdatePullRequest(t *testing.T) {
// Verify tool definition once
serverTool := UpdatePullRequest(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "draft")
assert.Contains(t, schema.Properties, "title")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "state")
assert.Contains(t, schema.Properties, "base")
assert.Contains(t, schema.Properties, "maintainer_can_modify")
assert.Contains(t, schema.Properties, "reviewers")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"})
// Setup mock PR for success case
mockUpdatedPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Updated Test PR Title"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Body: github.Ptr("Updated test PR body."),
MaintainerCanModify: github.Ptr(false),
Draft: github.Ptr(false),
Base: &github.PullRequestBranch{
Ref: github.Ptr("develop"),
},
}
mockClosedPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
State: github.Ptr("closed"), // State updated
}
// Mock PR for when there are no updates but we still need a response
mockPRWithReviewers := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
State: github.Ptr("open"),
RequestedReviewers: []*github.User{
{Login: github.Ptr("reviewer1")},
{Login: github.Ptr("reviewer2")},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedPR *github.PullRequest
expectedErrMsg string
}{
{
name: "successful PR update (title, body, base, maintainer_can_modify)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"title": "Updated Test PR Title",
"body": "Updated test PR body.",
"base": "develop",
"maintainer_can_modify": false,
}).andThen(
mockResponse(t, http.StatusOK, mockUpdatedPR),
),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated Test PR Title",
"body": "Updated test PR body.",
"base": "develop",
"maintainer_can_modify": false,
},
expectError: false,
expectedPR: mockUpdatedPR,
},
{
name: "successful PR update (state)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"state": "closed",
}).andThen(
mockResponse(t, http.StatusOK, mockClosedPR),
),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockClosedPR),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"state": "closed",
},
expectError: false,
expectedPR: mockClosedPR,
},
{
name: "successful PR update with reviewers",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"reviewers": []any{"reviewer1", "reviewer2"},
},
expectError: false,
expectedPR: mockPRWithReviewers,
},
{
name: "successful PR update with user and team reviewers",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"reviewers": []any{"reviewer1"},
"team_reviewers": []any{"platform"},
}).andThen(mockResponse(t, http.StatusOK, mockPRWithReviewers)),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"reviewers": []any{"reviewer1", "owner/platform"},
},
expectError: false,
expectedPR: mockPRWithReviewers,
},
{
name: "successful PR update (title only)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"title": "Updated Test PR Title",
}).andThen(
mockResponse(t, http.StatusOK, mockUpdatedPR),
),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated Test PR Title",
},
expectError: false,
expectedPR: mockUpdatedPR,
},
{
name: "no update parameters provided",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), // No API call expected
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
// No update fields
},
expectError: false, // Error is returned in the result, not as Go error
expectedErrMsg: "No update parameters provided",
},
{
name: "PR update fails (API error)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Invalid Title Causing Error",
},
expectError: true,
expectedErrMsg: "failed to update pull request",
},
{
name: "request reviewers fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"reviewers": []any{"invalid-user"},
},
expectError: true,
expectedErrMsg: "failed to request reviewers",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
gqlClient := githubv4.NewClient(nil)
deps := BaseDeps{
Client: client,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError || tc.expectedErrMsg != "" {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var updateResp MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL)
})
}
}
func Test_UpdatePullRequest_Draft(t *testing.T) {
// Setup mock PR for success case
mockUpdatedPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR Title"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Body: github.Ptr("Test PR body."),
MaintainerCanModify: github.Ptr(false),
Draft: github.Ptr(false), // Updated to ready for review
Base: &github.PullRequestBranch{
Ref: github.Ptr("main"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedPR *github.PullRequest
expectedErrMsg string
}{
{
name: "successful draft update to ready for review",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOA0xdyM50BPaO",
"isDraft": true, // Current state is draft
},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
MarkPullRequestReadyForReview struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
}
} `graphql:"markPullRequestReadyForReview(input: $input)"`
}{},
githubv4.MarkPullRequestReadyForReviewInput{
PullRequestID: "PR_kwDOA0xdyM50BPaO",
},
nil,
githubv4mock.DataResponse(map[string]any{
"markPullRequestReadyForReview": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOA0xdyM50BPaO",
"isDraft": false,
},
},
}),
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"draft": false,
},
expectError: false,
expectedPR: mockUpdatedPR,
},
{
name: "successful convert pull request to draft",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOA0xdyM50BPaO",
"isDraft": false, // Current state is draft
},
},
}),
),
githubv4mock.NewMutationMatcher(
struct {
ConvertPullRequestToDraft struct {
PullRequest struct {
ID githubv4.ID
IsDraft githubv4.Boolean
}
} `graphql:"convertPullRequestToDraft(input: $input)"`
}{},
githubv4.ConvertPullRequestToDraftInput{
PullRequestID: "PR_kwDOA0xdyM50BPaO",
},
nil,
githubv4mock.DataResponse(map[string]any{
"convertPullRequestToDraft": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDOA0xdyM50BPaO",
"isDraft": true,
},
},
}),
),
),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"draft": true,
},
expectError: false,
expectedPR: mockUpdatedPR,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// For draft-only tests, we need to mock both GraphQL and the final REST GET call
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR),
}))
gqlClient := githubv4.NewClient(tc.mockedClient)
serverTool := UpdatePullRequest(translations.NullTranslationHelper)
deps := BaseDeps{
Client: restClient,
GQLClient: gqlClient,
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
if tc.expectError || tc.expectedErrMsg != "" {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var updateResp MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &updateResp)
require.NoError(t, err)
assert.Equal(t, tc.expectedPR.GetHTMLURL(), updateResp.URL)
})
}
}
func Test_ListPullRequests(t *testing.T) {
// Verify tool definition once
serverTool := ListPullRequests(translations.NullTranslationHelper)
tool := serverTool.Tool
// ListPullRequests is the FeatureFlagFieldsParam-enabled variant; it owns
// the _ff_<flag> snapshot. The canonical list_pull_requests.snap is owned by
// LegacyListPullRequests (see Test_LegacyListPullRequests_Definition).
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
assert.Equal(t, "list_pull_requests", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "state")
assert.Contains(t, schema.Properties, "head")
assert.Contains(t, schema.Properties, "base")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "direction")
assert.Contains(t, schema.Properties, "perPage")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "fields")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
// Setup mock PRs for success case
mockPRs := []*github.PullRequest{
{
Number: github.Ptr(42),
Title: github.Ptr("First PR"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
},
{
Number: github.Ptr(43),
Title: github.Ptr("Second PR"),
State: github.Ptr("closed"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedPRs []*github.PullRequest
expectedErrMsg string
}{
{
name: "successful PRs listing",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{
"state": "all",
"sort": "created",
"direction": "desc",
"per_page": "30",
"page": "1",
}).andThen(
mockResponse(t, http.StatusOK, mockPRs),
),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"state": "all",
"sort": "created",
"direction": "desc",
"perPage": float64(30),
"page": float64(1),
},
expectError: false,
expectedPRs: mockPRs,
},
{
name: "PRs listing fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"state": "invalid",
},
expectError: true,
expectedErrMsg: "failed to list pull requests",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := ListPullRequests(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedPRs []MinimalPullRequest
err = json.Unmarshal([]byte(textContent.Text), &returnedPRs)
require.NoError(t, err)
assert.Len(t, returnedPRs, 2)
assert.Equal(t, *tc.expectedPRs[0].Number, returnedPRs[0].Number)
assert.Equal(t, *tc.expectedPRs[0].Title, returnedPRs[0].Title)
assert.Equal(t, *tc.expectedPRs[0].State, returnedPRs[0].State)
assert.Equal(t, *tc.expectedPRs[1].Number, returnedPRs[1].Number)
assert.Equal(t, *tc.expectedPRs[1].Title, returnedPRs[1].Title)
assert.Equal(t, *tc.expectedPRs[1].State, returnedPRs[1].State)
})
}
}
func Test_MergePullRequest(t *testing.T) {
// Verify tool definition once
serverTool := MergePullRequest(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "merge_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "commit_title")
assert.Contains(t, schema.Properties, "commit_message")
assert.Contains(t, schema.Properties, "merge_method")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"})
// Setup mock merge result for success case
mockMergeResult := &github.PullRequestMergeResult{
Merged: github.Ptr(true),
Message: github.Ptr("Pull Request successfully merged"),
SHA: github.Ptr("abcd1234efgh5678"),
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedMergeResult *github.PullRequestMergeResult
expectedErrMsg string
}{
{
name: "successful merge",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"commit_title": "Merge PR #42",
"commit_message": "Merging awesome feature",
"merge_method": "squash",
}).andThen(
mockResponse(t, http.StatusOK, mockMergeResult),
),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commit_title": "Merge PR #42",
"commit_message": "Merging awesome feature",
"merge_method": "squash",
},
expectError: false,
expectedMergeResult: mockMergeResult,
},
{
name: "merge fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PutReposPullsMergeByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
_, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: true,
expectedErrMsg: "failed to merge pull request",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := MergePullRequest(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult github.PullRequestMergeResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedMergeResult.Merged, *returnedResult.Merged)
assert.Equal(t, *tc.expectedMergeResult.Message, *returnedResult.Message)
assert.Equal(t, *tc.expectedMergeResult.SHA, *returnedResult.SHA)
})
}
}
func Test_SearchPullRequests(t *testing.T) {
serverTool := SearchPullRequests(translations.NullTranslationHelper)
tool := serverTool.Tool
// SearchPullRequests is the FeatureFlagFieldsParam-enabled variant; it owns
// the _ff_<flag> snapshot. The canonical search_pull_requests.snap is owned
// by LegacySearchPullRequests (see Test_LegacySearchPullRequests_Definition).
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
assert.Equal(t, "search_pull_requests", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "perPage")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "fields")
assert.ElementsMatch(t, schema.Required, []string{"query"})
mockSearchResult := &github.IssuesSearchResult{
Total: github.Ptr(2),
IncompleteResults: github.Ptr(false),
Issues: []*github.Issue{
{
Number: github.Ptr(42),
Title: github.Ptr("Test PR 1"),
Body: github.Ptr("Updated tests."),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
Comments: github.Ptr(5),
User: &github.User{
Login: github.Ptr("user1"),
},
},
{
Number: github.Ptr(43),
Title: github.Ptr("Test PR 2"),
Body: github.Ptr("Updated build scripts."),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"),
Comments: github.Ptr(3),
User: &github.User{
Login: github.Ptr("user2"),
},
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.IssuesSearchResult
expectedErrMsg string
}{
{
name: "successful pull request search with all parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
"perPage": float64(30),
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "pull request search with owner and repo parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "repo:test-owner/test-repo is:pr draft:false",
"sort": "updated",
"order": "asc",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "draft:false",
"owner": "test-owner",
"repo": "test-repo",
"sort": "updated",
"order": "asc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "pull request search with only owner parameter (should ignore it)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr feature",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "feature",
"owner": "test-owner",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "pull request search with only repo parameter (should ignore it)",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr review-required",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "review-required",
"repo": "test-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "pull request search with minimal parameters",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult),
}),
requestArgs: map[string]any{
"query": "is:pr repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing is:pr filter - no duplication",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server is:open draft:false",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:pr repo:github/github-mcp-server is:open draft:false",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "query with existing repo: filter and conflicting owner/repo params - uses query filter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server author:octocat",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "repo:github/github-mcp-server author:octocat",
"owner": "different-owner",
"repo": "different-repo",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "complex query with existing is:pr filter and OR operators",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: expectQueryParams(
t,
map[string]string{
"q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
"page": "1",
"per_page": "30",
},
).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search pull requests fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchIssues: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
},
}),
requestArgs: map[string]any{
"query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search pull requests",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := SearchPullRequests(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.IsError)
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedResult github.IssuesSearchResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total)
assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults)
assert.Len(t, returnedResult.Issues, len(tc.expectedResult.Issues))
for i, issue := range returnedResult.Issues {
assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
}
})
}
}
func Test_GetPullRequestFiles(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
// Setup mock PR files for success case
mockFiles := []*github.CommitFile{
{
Filename: github.Ptr("file1.go"),
Status: github.Ptr("modified"),
Additions: github.Ptr(10),
Deletions: github.Ptr(5),
Changes: github.Ptr(15),
Patch: github.Ptr("@@ -1,5 +1,10 @@"),
},
{
Filename: github.Ptr("file2.go"),
Status: github.Ptr("added"),
Additions: github.Ptr(20),
Deletions: github.Ptr(0),
Changes: github.Ptr(20),
Patch: github.Ptr("@@ -0,0 +1,20 @@"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedFiles []*github.CommitFile
expectedErrMsg string
}{
{
name: "successful files fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockFiles),
),
}),
requestArgs: map[string]any{
"method": "get_files",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedFiles: mockFiles,
},
{
name: "successful files fetch with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockFiles),
),
}),
requestArgs: map[string]any{
"method": "get_files",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedFiles: mockFiles,
},
{
name: "files fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "1",
"per_page": "30",
}).andThen(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
}),
requestArgs: map[string]any{
"method": "get_files",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request files",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedFiles []MinimalPRFile
err = json.Unmarshal([]byte(textContent.Text), &returnedFiles)
require.NoError(t, err)
assert.Len(t, returnedFiles, len(tc.expectedFiles))
for i, file := range returnedFiles {
assert.Equal(t, tc.expectedFiles[i].GetFilename(), file.Filename)
assert.Equal(t, tc.expectedFiles[i].GetStatus(), file.Status)
assert.Equal(t, tc.expectedFiles[i].GetAdditions(), file.Additions)
assert.Equal(t, tc.expectedFiles[i].GetDeletions(), file.Deletions)
}
})
}
}
func Test_GetPullRequestCommits(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC)
mockCommits := []*github.RepositoryCommit{
{
SHA: github.Ptr("abc123def456"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
Commit: &github.Commit{
Message: github.Ptr("feat: add commit listing"),
Author: &github.CommitAuthor{
Name: github.Ptr("Test User"),
Email: github.Ptr("test@example.com"),
Date: &github.Timestamp{Time: authorDate},
},
Committer: &github.CommitAuthor{
Name: github.Ptr("Merge Bot"),
Email: github.Ptr("merge@example.com"),
Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)},
},
},
Author: &github.User{
Login: github.Ptr("test-user"),
ID: github.Ptr(int64(12345)),
HTMLURL: github.Ptr("https://github.com/test-user"),
AvatarURL: github.Ptr("https://github.com/test-user.png"),
},
Committer: &github.User{
Login: github.Ptr("merge-bot"),
ID: github.Ptr(int64(67890)),
HTMLURL: github.Ptr("https://github.com/merge-bot"),
AvatarURL: github.Ptr("https://github.com/merge-bot.png"),
},
},
{
SHA: github.Ptr("def456abc789"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
Commit: &github.Commit{
Message: github.Ptr("fix: handle pagination"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedCommits []*github.RepositoryCommit
expectedErrMsg string
}{
{
name: "successful commits fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockCommits),
),
}),
requestArgs: map[string]any{
"method": "get_commits",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedCommits: mockCommits,
},
{
name: "successful commits fetch with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockCommits),
),
}),
requestArgs: map[string]any{
"method": "get_commits",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedCommits: mockCommits,
},
{
name: "commits fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "1",
"per_page": "30",
}).andThen(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
}),
requestArgs: map[string]any{
"method": "get_commits",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request commits",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := mustNewGHClient(t, tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
assert.NotContains(t, textContent.Text, `"committer"`)
assert.NotContains(t, textContent.Text, `"profile_url"`)
var returnedCommits []MinimalPullRequestCommit
err = json.Unmarshal([]byte(textContent.Text), &returnedCommits)
require.NoError(t, err)
assert.Len(t, returnedCommits, len(tc.expectedCommits))
for i, commit := range returnedCommits {
assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA)
assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL)
assert.Equal(t, tc.expectedCommits[i].GetCommit().GetMessage(), commit.Message)
}
assert.Equal(t, authorDate.Format(time.RFC3339), returnedCommits[0].Author.Date)
})
}
}
func Test_ConvertToMinimalPullRequestCommitsSkipsNilCommit(t *testing.T) {
commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{nil})
require.Empty(t, commits)
}
func Test_GetPullRequestStatus(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
// Setup mock PR for successful PR fetch
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{
SHA: github.Ptr("abcd1234"),
Ref: github.Ptr("feature-branch"),
},
}
// Setup mock status for success case
mockStatus := &github.CombinedStatus{
State: github.Ptr("success"),
TotalCount: github.Ptr(3),
Statuses: []*github.RepoStatus{
{
State: github.Ptr("success"),
Context: github.Ptr("continuous-integration/travis-ci"),
Description: github.Ptr("Build succeeded"),
TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"),
},
{
State: github.Ptr("success"),
Context: github.Ptr("codecov/patch"),
Description: github.Ptr("Coverage increased"),
TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"),
},
{
State: github.Ptr("success"),
Context: github.Ptr("lint/golangci-lint"),
Description: github.Ptr("No issues found"),
TargetURL: github.Ptr("https://golangci.com/r/owner/repo/pull/42"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedStatus *github.CombinedStatus
expectedErrMsg string
}{
{
name: "successful status fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsStatusByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockStatus),
}),
requestArgs: map[string]any{
"method": "get_status",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedStatus: mockStatus,
},
{
name: "PR fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]any{
"method": "get_status",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request",
},
{
name: "status fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsStatusesByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]any{
"method": "get_status",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: true,
expectedErrMsg: "failed to get combined status",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedStatus github.CombinedStatus
err = json.Unmarshal([]byte(textContent.Text), &returnedStatus)
require.NoError(t, err)
assert.Equal(t, *tc.expectedStatus.State, *returnedStatus.State)
assert.Equal(t, *tc.expectedStatus.TotalCount, *returnedStatus.TotalCount)
assert.Len(t, returnedStatus.Statuses, len(tc.expectedStatus.Statuses))
for i, status := range returnedStatus.Statuses {
assert.Equal(t, *tc.expectedStatus.Statuses[i].State, *status.State)
assert.Equal(t, *tc.expectedStatus.Statuses[i].Context, *status.Context)
assert.Equal(t, *tc.expectedStatus.Statuses[i].Description, *status.Description)
}
})
}
}
func Test_GetPullRequestCheckRuns(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
// Setup mock PR for successful PR fetch
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{
SHA: github.Ptr("abcd1234"),
Ref: github.Ptr("feature-branch"),
},
}
// Setup mock check runs for success case
mockCheckRuns := &github.ListCheckRunsResults{
Total: github.Ptr(2),
CheckRuns: []*github.CheckRun{
{
ID: github.Ptr(int64(1)),
Name: github.Ptr("build"),
Status: github.Ptr("completed"),
Conclusion: github.Ptr("success"),
HTMLURL: github.Ptr("https://github.com/owner/repo/runs/1"),
},
{
ID: github.Ptr(int64(2)),
Name: github.Ptr("test"),
Status: github.Ptr("completed"),
Conclusion: github.Ptr("success"),
HTMLURL: github.Ptr("https://github.com/owner/repo/runs/2"),
},
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedCheckRuns *github.ListCheckRunsResults
expectedErrMsg string
}{
{
name: "successful check runs fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsCheckRunsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCheckRuns),
}),
requestArgs: map[string]any{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedCheckRuns: mockCheckRuns,
},
{
name: "PR fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]any{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request",
},
{
name: "check runs fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposCommitsCheckRunsByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
}),
requestArgs: map[string]any{
"method": "get_check_runs",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: true,
expectedErrMsg: "failed to get check runs",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result (using minimal type)
var returnedCheckRuns MinimalCheckRunsResult
err = json.Unmarshal([]byte(textContent.Text), &returnedCheckRuns)
require.NoError(t, err)
assert.Equal(t, *tc.expectedCheckRuns.Total, returnedCheckRuns.TotalCount)
assert.Len(t, returnedCheckRuns.CheckRuns, len(tc.expectedCheckRuns.CheckRuns))
for i, checkRun := range returnedCheckRuns.CheckRuns {
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Name, checkRun.Name)
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Status, checkRun.Status)
assert.Equal(t, *tc.expectedCheckRuns.CheckRuns[i].Conclusion, checkRun.Conclusion)
}
})
}
}
func Test_UpdatePullRequestBranch(t *testing.T) {
// Verify tool definition once
serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_pull_request_branch", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "expectedHeadSha")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"})
// Setup mock update result for success case
mockUpdateResult := &github.PullRequestBranchUpdateResponse{
Message: github.Ptr("Branch was updated successfully"),
URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"),
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedUpdateResult *github.PullRequestBranchUpdateResponse
expectedErrMsg string
}{
{
name: "successful branch update",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{
"expected_head_sha": "abcd1234",
}).andThen(
mockResponse(t, http.StatusAccepted, mockUpdateResult),
),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"expectedHeadSha": "abcd1234",
},
expectError: false,
expectedUpdateResult: mockUpdateResult,
},
{
name: "branch update without expected SHA",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{}).andThen(
mockResponse(t, http.StatusAccepted, mockUpdateResult),
),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedUpdateResult: mockUpdateResult,
},
{
name: "branch update fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"message": "Merge conflict"}`))
}),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: true,
expectedErrMsg: "failed to update pull request branch",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "is in progress")
})
}
}
func Test_GetPullRequestComments(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
// `after` is required for cursor-based pagination on get_review_comments
// to be reachable from MCP clients; without it in the schema, callers
// cannot advance past the first page (issue #2122).
assert.Contains(t, schema.Properties, "after")
assert.Equal(t, "string", schema.Properties["after"].Type)
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
tests := []struct {
name string
gqlHTTPClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
lockdownEnabled bool
validateResult func(t *testing.T, textContent string)
}{
{
name: "successful review threads fetch",
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
reviewThreadsQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
"first": githubv4.Int(30),
"commentsPerThread": githubv4.Int(100),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"reviewThreads": map[string]any{
"nodes": []map[string]any{
{
"id": "RT_kwDOA0xdyM4AX1Yz",
"isResolved": false,
"isOutdated": false,
"isCollapsed": false,
"comments": map[string]any{
"totalCount": 2,
"nodes": []map[string]any{
{
"id": "PRRC_kwDOA0xdyM4AX1Y0",
"body": "This looks good",
"path": "file1.go",
"line": 5,
"author": map[string]any{
"login": "reviewer1",
},
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z",
"url": "https://github.com/owner/repo/pull/42#discussion_r101",
},
{
"id": "PRRC_kwDOA0xdyM4AX1Y1",
"body": "Please fix this",
"path": "file1.go",
"line": 10,
"author": map[string]any{
"login": "reviewer2",
},
"createdAt": "2024-01-01T13:00:00Z",
"updatedAt": "2024-01-01T13:00:00Z",
"url": "https://github.com/owner/repo/pull/42#discussion_r102",
},
},
},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "cursor1",
"endCursor": "cursor2",
},
"totalCount": 1,
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "get_review_comments",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
validateResult: func(t *testing.T, textContent string) {
var result MinimalReviewThreadsResponse
err := json.Unmarshal([]byte(textContent), &result)
require.NoError(t, err)
// Validate review threads
assert.Len(t, result.ReviewThreads, 1)
thread := result.ReviewThreads[0]
assert.Equal(t, false, thread.IsResolved)
assert.Equal(t, false, thread.IsOutdated)
assert.Equal(t, false, thread.IsCollapsed)
// Validate comments within thread
assert.Len(t, thread.Comments, 2)
// Validate first comment
comment1 := thread.Comments[0]
assert.Equal(t, "This looks good", comment1.Body)
assert.Equal(t, "file1.go", comment1.Path)
assert.Equal(t, "reviewer1", comment1.Author)
// Validate pagination info
assert.Equal(t, false, result.PageInfo.HasNextPage)
assert.Equal(t, false, result.PageInfo.HasPreviousPage)
assert.Equal(t, "cursor1", result.PageInfo.StartCursor)
assert.Equal(t, "cursor2", result.PageInfo.EndCursor)
// Validate total count
assert.Equal(t, 1, result.TotalCount)
},
},
{
name: "after cursor is forwarded to GraphQL query",
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
reviewThreadsQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
"first": githubv4.Int(30),
"commentsPerThread": githubv4.Int(100),
"after": githubv4.String("cursor-page-2"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"reviewThreads": map[string]any{
"nodes": []map[string]any{},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": true,
"startCursor": "cursor3",
"endCursor": "cursor4",
},
"totalCount": 5,
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "get_review_comments",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"after": "cursor-page-2",
},
expectError: false,
validateResult: func(t *testing.T, textContent string) {
var result MinimalReviewThreadsResponse
err := json.Unmarshal([]byte(textContent), &result)
require.NoError(t, err)
assert.Len(t, result.ReviewThreads, 0)
assert.Equal(t, true, result.PageInfo.HasPreviousPage)
assert.Equal(t, "cursor4", result.PageInfo.EndCursor)
},
},
{
name: "review threads fetch fails",
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
reviewThreadsQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(999),
"first": githubv4.Int(30),
"commentsPerThread": githubv4.Int(100),
"after": (*githubv4.String)(nil),
},
githubv4mock.ErrorResponse("Could not resolve to a PullRequest with the number of 999."),
),
),
requestArgs: map[string]any{
"method": "get_review_comments",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request review threads",
},
{
name: "lockdown enabled filters review comments without push access",
gqlHTTPClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
reviewThreadsQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
"first": githubv4.Int(30),
"commentsPerThread": githubv4.Int(100),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"reviewThreads": map[string]any{
"nodes": []map[string]any{
{
"id": "RT_kwDOA0xdyM4AX1Yz",
"isResolved": false,
"isOutdated": false,
"isCollapsed": false,
"comments": map[string]any{
"totalCount": 2,
"nodes": []map[string]any{
{
"id": "PRRC_kwDOA0xdyM4AX1Y0",
"body": "Maintainer review comment",
"path": "file1.go",
"line": 5,
"author": map[string]any{
"login": "maintainer",
},
"createdAt": "2024-01-01T12:00:00Z",
"updatedAt": "2024-01-01T12:00:00Z",
"url": "https://github.com/owner/repo/pull/42#discussion_r2010",
},
{
"id": "PRRC_kwDOA0xdyM4AX1Y1",
"body": "External review comment",
"path": "file1.go",
"line": 10,
"author": map[string]any{
"login": "testuser",
},
"createdAt": "2024-01-01T13:00:00Z",
"updatedAt": "2024-01-01T13:00:00Z",
"url": "https://github.com/owner/repo/pull/42#discussion_r2011",
},
},
},
},
},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "cursor1",
"endCursor": "cursor2",
},
"totalCount": 1,
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "get_review_comments",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
lockdownEnabled: true,
validateResult: func(t *testing.T, textContent string) {
var result MinimalReviewThreadsResponse
err := json.Unmarshal([]byte(textContent), &result)
require.NoError(t, err)
// Validate that only maintainer comment is returned
assert.Len(t, result.ReviewThreads, 1)
thread := result.ReviewThreads[0]
// Should only have 1 comment (maintainer) after filtering
assert.Equal(t, 1, thread.TotalCount)
assert.Len(t, thread.Comments, 1)
comment := thread.Comments[0]
assert.Equal(t, "maintainer", comment.Author)
assert.Equal(t, "Maintainer review comment", comment.Body)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup GraphQL client with mock
var gqlClient *githubv4.Client
if tc.gqlHTTPClient != nil {
gqlClient = githubv4.NewClient(tc.gqlHTTPClient)
} else {
gqlClient = githubv4.NewClient(nil)
}
// Setup cache for lockdown mode
var restClient *github.Client
if tc.lockdownEnabled {
restClient = mockRESTPermissionServer(t, "read", map[string]string{
"maintainer": "write",
"external-user": "read",
"testuser": "read",
})
}
cache := stubRepoAccessCache(restClient, 5*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: mustNewGHClient(t, nil),
GQLClient: gqlClient,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Use custom validation if provided
if tc.validateResult != nil {
tc.validateResult(t, textContent.Text)
}
})
}
}
func Test_GetPullRequestReviews(t *testing.T) {
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
// Setup mock PR reviews for success case
mockReviews := []*github.PullRequestReview{
{
ID: github.Ptr(int64(201)),
State: github.Ptr("APPROVED"),
Body: github.Ptr("LGTM"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"),
User: &github.User{
Login: github.Ptr("approver"),
},
CommitID: github.Ptr("abcdef123456"),
SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)},
},
{
ID: github.Ptr(int64(202)),
State: github.Ptr("CHANGES_REQUESTED"),
Body: github.Ptr("Please address the following issues"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"),
User: &github.User{
Login: github.Ptr("reviewer"),
},
CommitID: github.Ptr("abcdef123456"),
SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)},
},
}
tests := []struct {
name string
mockedClient *http.Client
gqlHTTPClient *http.Client
requestArgs map[string]any
expectError bool
expectedReviews []*github.PullRequestReview
expectedErrMsg string
lockdownEnabled bool
}{
{
name: "successful reviews fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockReviews),
}),
requestArgs: map[string]any{
"method": "get_reviews",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedReviews: mockReviews,
},
{
name: "successful reviews fetch with pagination",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "2",
"per_page": "10",
}).andThen(
mockResponse(t, http.StatusOK, mockReviews),
),
}),
requestArgs: map[string]any{
"method": "get_reviews",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"page": float64(2),
"perPage": float64(10),
},
expectError: false,
expectedReviews: mockReviews,
},
{
name: "reviews fetch fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{
"page": "1",
"per_page": "30",
}).andThen(
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
}),
requestArgs: map[string]any{
"method": "get_reviews",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(999),
},
expectError: true,
expectedErrMsg: "failed to get pull request reviews",
},
{
name: "lockdown enabled filters reviews without push access",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{
{
ID: github.Ptr(int64(2030)),
State: github.Ptr("APPROVED"),
Body: github.Ptr("Maintainer review"),
User: &github.User{Login: github.Ptr("maintainer")},
},
{
ID: github.Ptr(int64(2031)),
State: github.Ptr("COMMENTED"),
Body: github.Ptr("External reviewer"),
User: &github.User{Login: github.Ptr("testuser")},
},
}),
}),
requestArgs: map[string]any{
"method": "get_reviews",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
expectError: false,
expectedReviews: []*github.PullRequestReview{
{
ID: github.Ptr(int64(2030)),
State: github.Ptr("APPROVED"),
Body: github.Ptr("Maintainer review"),
User: &github.User{Login: github.Ptr("maintainer")},
},
},
lockdownEnabled: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
var restClient *github.Client
if tc.lockdownEnabled {
restClient = mockRESTPermissionServer(t, "read", map[string]string{
"maintainer": "write",
"testuser": "read",
})
}
cache := stubRepoAccessCache(restClient, 5*time.Minute)
flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled})
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: cache,
Flags: flags,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedReviews []MinimalPullRequestReview
err = json.Unmarshal([]byte(textContent.Text), &returnedReviews)
require.NoError(t, err)
assert.Len(t, returnedReviews, len(tc.expectedReviews))
for i, review := range returnedReviews {
assert.Equal(t, tc.expectedReviews[i].GetID(), review.ID)
assert.Equal(t, tc.expectedReviews[i].GetState(), review.State)
assert.Equal(t, tc.expectedReviews[i].GetBody(), review.Body)
require.NotNil(t, tc.expectedReviews[i].User)
require.NotNil(t, review.User)
assert.Equal(t, tc.expectedReviews[i].GetUser().GetLogin(), review.User.Login)
assert.Equal(t, tc.expectedReviews[i].GetHTMLURL(), review.HTMLURL)
}
})
}
}
func Test_CreatePullRequest(t *testing.T) {
// Verify tool definition once
serverTool := CreatePullRequest(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "title")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "head")
assert.Contains(t, schema.Properties, "base")
assert.Contains(t, schema.Properties, "draft")
assert.Contains(t, schema.Properties, "maintainer_can_modify")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "title", "head", "base"})
// Setup mock PR for success case
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{
SHA: github.Ptr("abcd1234"),
Ref: github.Ptr("feature-branch"),
},
Base: &github.PullRequestBranch{
SHA: github.Ptr("efgh5678"),
Ref: github.Ptr("main"),
},
Body: github.Ptr("This is a test PR"),
Draft: github.Ptr(false),
MaintainerCanModify: github.Ptr(true),
User: &github.User{
Login: github.Ptr("testuser"),
},
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedPR *github.PullRequest
expectedErrMsg string
}{
{
name: "successful PR creation",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]any{
"title": "Test PR",
"body": "This is a test PR",
"head": "feature-branch",
"base": "main",
"draft": false,
"maintainer_can_modify": true,
}).andThen(
mockResponse(t, http.StatusCreated, mockPR),
),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"body": "This is a test PR",
"head": "feature-branch",
"base": "main",
"draft": false,
"maintainer_can_modify": true,
},
expectError: false,
expectedPR: mockPR,
},
{
name: "missing required parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
// missing title, head, base
},
expectError: true,
expectedErrMsg: "missing required parameter: title",
},
{
name: "PR creation fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`))
}),
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"head": "feature-branch",
"base": "main",
},
expectError: true,
expectedErrMsg: "failed to create pull request",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := CreatePullRequest(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
// Verify results
if tc.expectError {
if err != nil {
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
// If no error returned but in the result
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the minimal result
var returnedPR MinimalResponse
err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
require.NoError(t, err)
assert.Equal(t, tc.expectedPR.GetHTMLURL(), returnedPR.URL)
})
}
}
// Test_CreatePullRequest_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate
// behavior: UI clients get a form message, non-UI clients execute directly.
func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
t.Parallel()
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Test PR"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")},
Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")},
User: &github.User{Login: github.Ptr("testuser")},
}
serverTool := CreatePullRequest(translations.NullTranslationHelper)
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR),
}))
deps := BaseDeps{
Client: client,
GQLClient: githubv4.NewClient(nil),
featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
}
handler := serverTool.Handler(deps)
t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"head": "feature",
"base": "main",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"head": "feature",
"base": "main",
"_ui_submitted": true,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"tool should return the created PR URL")
})
t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) {
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"head": "feature",
"base": "main",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"non-UI client should execute directly")
})
t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) {
// A parameter the form does not collect must bypass the form rather than
// be silently dropped.
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"title": "Test PR",
"head": "feature",
"base": "main",
"unknown_param": "value",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.NotContains(t, textContent.Text, "interactive form has been shown",
"non-form param should skip UI form")
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"non-form param call should execute directly and return PR URL")
})
}
// Test_UpdatePullRequest_MCPAppsFeature_UIGate verifies the form-routing
// behavior for update_pull_request: UI clients without _ui_submitted get a
// pending-form stub (marked IsError so agents don't claim success), UI clients
// with _ui_submitted execute directly, non-UI clients execute directly, and
// UI clients carrying non-form params bypass the form.
func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
t.Parallel()
mockPR := &github.PullRequest{
Number: github.Ptr(42),
Title: github.Ptr("Updated"),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")},
Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")},
User: &github.User{Login: github.Ptr("testuser")},
}
serverTool := UpdatePullRequest(translations.NullTranslationHelper)
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR),
}))
deps := BaseDeps{
Client: client,
GQLClient: githubv4.NewClient(nil),
featureChecker: featureCheckerFor(MCPAppsFeatureFlag),
}
handler := serverTool.Handler(deps)
t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing pull request #42")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")
})
t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated",
"_ui_submitted": true,
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.False(t, result.IsError, "submitted form should execute successfully: %s", textContent.Text)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"submitted form should return the updated PR URL")
})
t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) {
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.False(t, result.IsError, "non-UI client should execute directly: %s", textContent.Text)
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
"non-UI client should return the updated PR URL")
})
t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) {
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"title": "Updated",
"unknown_param": "value",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
assert.NotContains(t, textContent.Text, "interactive form has been shown",
"non-form param should skip UI form")
})
}
func Test_pullRequestWriteHasNonFormParams(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args map[string]any
want bool
}{
{name: "no params", args: map[string]any{}, want: false},
{name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "reviewers": []any{"octocat"}, "_ui_submitted": true}, want: false},
{name: "unknown param present", args: map[string]any{"title": "t", "unknown_param": "value"}, want: true},
{name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, hasNonFormParams(tc.args, pullRequestWriteFormParams))
})
}
}
// Test_createPullRequestSchemaClassification fails when a schema property is
// added without classifying it as either form-resendable
// (pullRequestWriteFormParams) or known-non-form (knownNonForm below).
// Today every property is form-resendable, so knownNonForm is empty.
func Test_createPullRequestSchemaClassification(t *testing.T) {
t.Parallel()
knownNonForm := map[string]struct{}{}
tool := CreatePullRequest(translations.NullTranslationHelper)
schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
for prop := range schema.Properties {
_, isForm := pullRequestWriteFormParams[prop]
_, isNonForm := knownNonForm[prop]
assert.Falsef(t, isForm && isNonForm,
"property %q is classified as both form-resendable and non-form — pick one", prop)
assert.Truef(t, isForm || isNonForm,
"property %q in create_pull_request schema is unclassified — add it to pullRequestWriteFormParams "+
"(pkg/github/pullrequests.go) if the MCP App form can carry it on submit, otherwise add it to "+
"the knownNonForm allowlist in this test", prop)
}
}
func TestCreateAndSubmitPullRequestReview(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_review_write", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "event")
assert.Contains(t, schema.Properties, "commitID")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful review creation",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDODKw3uc6WYN1T",
},
},
},
),
),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReview(input: $input)"`
}{},
githubv4.AddPullRequestReviewInput{
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
Body: githubv4.NewString("This is a test review"),
Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment),
CommitOID: githubv4.NewGitObjectID("abcd1234"),
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"body": "This is a test review",
"event": "COMMENT",
"commitID": "abcd1234",
},
expectToolError: false,
},
{
name: "successful review creation with string pullNumber",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDODKw3uc6WYN1T",
},
},
},
),
),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReview(input: $input)"`
}{},
githubv4.AddPullRequestReviewInput{
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
Body: githubv4.NewString("This is a test review"),
Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment),
CommitOID: githubv4.NewGitObjectID("abcd1234"),
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": "42", // Some MCP clients send numeric values as strings
"body": "This is a test review",
"event": "COMMENT",
"commitID": "abcd1234",
},
expectToolError: false,
},
{
name: "failure to get pull request",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.ErrorResponse("expected test failure"),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"body": "This is a test review",
"event": "COMMENT",
"commitID": "abcd1234",
},
expectToolError: true,
expectedToolErrMsg: "expected test failure",
},
{
name: "failure to submit review",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDODKw3uc6WYN1T",
},
},
},
),
),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReview(input: $input)"`
}{},
githubv4.AddPullRequestReviewInput{
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
Body: githubv4.NewString("This is a test review"),
Event: githubv4mock.Ptr(githubv4.PullRequestReviewEventComment),
CommitOID: githubv4.NewGitObjectID("abcd1234"),
},
nil,
githubv4mock.ErrorResponse("expected test failure"),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"body": "This is a test review",
"event": "COMMENT",
"commitID": "abcd1234",
},
expectToolError: true,
expectedToolErrMsg: "expected test failure",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, textContent.Text, "pull request review submitted successfully")
})
}
}
func TestCreatePendingPullRequestReview(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_review_write", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "commitID")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful review creation",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDODKw3uc6WYN1T",
},
},
},
),
),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReview(input: $input)"`
}{},
githubv4.AddPullRequestReviewInput{
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
CommitOID: githubv4.NewGitObjectID("abcd1234"),
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commitID": "abcd1234",
},
expectToolError: false,
},
{
name: "failure to get pull request",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.ErrorResponse("expected test failure"),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commitID": "abcd1234",
},
expectToolError: true,
expectedToolErrMsg: "expected test failure",
},
{
name: "failure to create pending review",
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
ID githubv4.ID
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"prNum": githubv4.Int(42),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"id": "PR_kwDODKw3uc6WYN1T",
},
},
},
),
),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReview(input: $input)"`
}{},
githubv4.AddPullRequestReviewInput{
PullRequestID: githubv4.ID("PR_kwDODKw3uc6WYN1T"),
CommitOID: githubv4.NewGitObjectID("abcd1234"),
},
nil,
githubv4mock.ErrorResponse("expected test failure"),
),
),
requestArgs: map[string]any{
"method": "create",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commitID": "abcd1234",
},
expectToolError: true,
expectedToolErrMsg: "expected test failure",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, "pending pull request created", textContent.Text)
})
}
}
func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := AddCommentToPendingReview(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_comment_to_pending_review", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "path")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "subjectType")
assert.Contains(t, schema.Properties, "line")
assert.Contains(t, schema.Properties, "side")
assert.Contains(t, schema.Properties, "startLine")
assert.Contains(t, schema.Properties, "startSide")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"})
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful line comment addition",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"path": "file.go",
"body": "This is a test comment",
"subjectType": "LINE",
"line": float64(10),
"side": "RIGHT",
"startLine": float64(5),
"startSide": "RIGHT",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
viewerQuery("williammartin"),
getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
author: "williammartin",
owner: "owner",
repo: "repo",
prNum: 42,
reviews: []getLatestPendingReviewQueryReview{
{
id: "PR_kwDODKw3uc6WYN1T",
state: "PENDING",
url: "https://github.com/owner/repo/pull/42",
},
},
}),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReviewThread struct {
Thread struct {
ID githubv4.String // We don't need this, but a selector is required or GQL complains.
}
} `graphql:"addPullRequestReviewThread(input: $input)"`
}{},
githubv4.AddPullRequestReviewThreadInput{
Path: githubv4.String("file.go"),
Body: githubv4.String("This is a test comment"),
SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine),
Line: githubv4.NewInt(10),
Side: githubv4mock.Ptr(githubv4.DiffSideRight),
StartLine: githubv4.NewInt(5),
StartSide: githubv4mock.Ptr(githubv4.DiffSideRight),
PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"addPullRequestReviewThread": map[string]any{
"thread": map[string]any{
"id": "MDEyOlB1bGxSZXF1ZXN0UmV2aWV3VGhyZWFkMTIzNDU2",
},
},
}),
),
),
},
{
name: "successful line comment with string pullNumber and line",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": "42", // Some MCP clients send numeric values as strings
"path": "file.go",
"body": "This is a test comment",
"subjectType": "LINE",
"line": "10", // string line number
"side": "RIGHT",
"startLine": "5", // string startLine
"startSide": "RIGHT",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
viewerQuery("williammartin"),
getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
author: "williammartin",
owner: "owner",
repo: "repo",
prNum: 42,
reviews: []getLatestPendingReviewQueryReview{
{
id: "PR_kwDODKw3uc6WYN1T",
state: "PENDING",
url: "https://github.com/owner/repo/pull/42",
},
},
}),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReviewThread struct {
Thread struct {
ID githubv4.String
}
} `graphql:"addPullRequestReviewThread(input: $input)"`
}{},
githubv4.AddPullRequestReviewThreadInput{
Path: githubv4.String("file.go"),
Body: githubv4.String("This is a test comment"),
SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine),
Line: githubv4.NewInt(10),
Side: githubv4mock.Ptr(githubv4.DiffSideRight),
StartLine: githubv4.NewInt(5),
StartSide: githubv4mock.Ptr(githubv4.DiffSideRight),
PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"addPullRequestReviewThread": map[string]any{
"thread": map[string]any{
"id": "MDEyOlB1bGxSZXF1ZXN0UmV2aWV3VGhyZWFkMTIzNDU2",
},
},
}),
),
),
},
{
name: "missing required parameter owner",
requestArgs: map[string]any{
"repo": "gated-probe",
"pullNumber": float64(1),
"path": "f.go",
"body": "x",
"subjectType": "LINE",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter path",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"body": "This is a test comment",
"subjectType": "LINE",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: path",
},
{
name: "thread ID is nil - invalid line number",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"path": "file.go",
"body": "Comment on non-existent line",
"subjectType": "LINE",
"line": float64(999),
"side": "RIGHT",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
viewerQuery("williammartin"),
getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
author: "williammartin",
owner: "owner",
repo: "repo",
prNum: 42,
reviews: []getLatestPendingReviewQueryReview{
{
id: "PR_kwDODKw3uc6WYN1T",
state: "PENDING",
url: "https://github.com/owner/repo/pull/42",
},
},
}),
githubv4mock.NewMutationMatcher(
struct {
AddPullRequestReviewThread struct {
Thread struct {
ID githubv4.ID
}
} `graphql:"addPullRequestReviewThread(input: $input)"`
}{},
githubv4.AddPullRequestReviewThreadInput{
Path: githubv4.String("file.go"),
Body: githubv4.String("Comment on non-existent line"),
SubjectType: githubv4mock.Ptr(githubv4.PullRequestReviewThreadSubjectTypeLine),
Line: githubv4.NewInt(999),
Side: githubv4mock.Ptr(githubv4.DiffSideRight),
StartLine: nil,
StartSide: nil,
PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"addPullRequestReviewThread": map[string]any{
"thread": map[string]any{
"id": nil,
},
},
}),
),
),
expectToolError: true,
expectedToolErrMsg: "Failed to add comment to pending review",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := AddCommentToPendingReview(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, textContent.Text, "pull request review comment successfully added to pending review")
})
}
}
func TestSubmitPendingPullRequestReview(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_review_write", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "event")
assert.Contains(t, schema.Properties, "body")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful review submission",
requestArgs: map[string]any{
"method": "submit_pending",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"event": "COMMENT",
"body": "This is a test review",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
viewerQuery("williammartin"),
getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
author: "williammartin",
owner: "owner",
repo: "repo",
prNum: 42,
reviews: []getLatestPendingReviewQueryReview{
{
id: "PR_kwDODKw3uc6WYN1T",
state: "PENDING",
url: "https://github.com/owner/repo/pull/42",
},
},
}),
githubv4mock.NewMutationMatcher(
struct {
SubmitPullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"submitPullRequestReview(input: $input)"`
}{},
githubv4.SubmitPullRequestReviewInput{
PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
Event: githubv4.PullRequestReviewEventComment,
Body: githubv4.NewString("This is a test review"),
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, "pending pull request review successfully submitted", textContent.Text)
})
}
}
func TestDeletePendingPullRequestReview(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_review_write", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful review deletion",
requestArgs: map[string]any{
"method": "delete_pending",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
mockedClient: githubv4mock.NewMockedHTTPClient(
viewerQuery("williammartin"),
getLatestPendingReviewQuery(getLatestPendingReviewQueryParams{
author: "williammartin",
owner: "owner",
repo: "repo",
prNum: 42,
reviews: []getLatestPendingReviewQueryReview{
{
id: "PR_kwDODKw3uc6WYN1T",
state: "PENDING",
url: "https://github.com/owner/repo/pull/42",
},
},
}),
githubv4mock.NewMutationMatcher(
struct {
DeletePullRequestReview struct {
PullRequestReview struct {
ID githubv4.ID
}
} `graphql:"deletePullRequestReview(input: $input)"`
}{},
githubv4.DeletePullRequestReviewInput{
PullRequestReviewID: githubv4.NewID("PR_kwDODKw3uc6WYN1T"),
},
nil,
githubv4mock.DataResponse(map[string]any{}),
),
),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, "pending pull request review successfully deleted", textContent.Text)
})
}
}
func TestGetPullRequestDiff(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := PullRequestRead(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "pull_request_read", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "method")
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"})
stubbedDiff := `diff --git a/README.md b/README.md
index 5d6e7b2..8a4f5c3 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
# Hello-World
Hello World project for GitHub
+## New Section
+
+This is a new section added in the pull request.`
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
}{
{
name: "successful diff retrieval",
requestArgs: map[string]any{
"method": "get_diff",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposPullsByOwnerByRepoByPullNumber: expectPath(t, "/repos/owner/repo/pulls/42").andThen(
mockResponse(t, http.StatusOK, stubbedDiff),
),
}),
expectToolError: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := PullRequestRead(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
// Parse the result and get the text content if no error
require.Equal(t, stubbedDiff, textContent.Text)
})
}
}
func viewerQuery(login string) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Viewer struct {
Login githubv4.String
} `graphql:"viewer"`
}{},
map[string]any{},
githubv4mock.DataResponse(map[string]any{
"viewer": map[string]any{
"login": login,
},
}),
)
}
type getLatestPendingReviewQueryReview struct {
id string
state string
url string
}
type getLatestPendingReviewQueryParams struct {
author string
owner string
repo string
prNum int32
reviews []getLatestPendingReviewQueryReview
}
func getLatestPendingReviewQuery(p getLatestPendingReviewQueryParams) githubv4mock.Matcher {
return githubv4mock.NewQueryMatcher(
struct {
Repository struct {
PullRequest struct {
Reviews struct {
Nodes []struct {
ID githubv4.ID
State githubv4.PullRequestReviewState
URL githubv4.URI
}
} `graphql:"reviews(first: 1, author: $author)"`
} `graphql:"pullRequest(number: $prNum)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}{},
map[string]any{
"author": githubv4.String(p.author),
"owner": githubv4.String(p.owner),
"name": githubv4.String(p.repo),
"prNum": githubv4.Int(p.prNum),
},
githubv4mock.DataResponse(
map[string]any{
"repository": map[string]any{
"pullRequest": map[string]any{
"reviews": map[string]any{
"nodes": []any{
map[string]any{
"id": p.reviews[0].id,
"state": p.reviews[0].state,
"url": p.reviews[0].url,
},
},
},
},
},
},
),
)
}
func TestAddReplyToPullRequestComment(t *testing.T) {
t.Parallel()
// Verify tool definition once
serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_reply_to_pull_request_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
schema := tool.InputSchema.(*jsonschema.Schema)
assert.Contains(t, schema.Properties, "owner")
assert.Contains(t, schema.Properties, "repo")
assert.Contains(t, schema.Properties, "pullNumber")
assert.Contains(t, schema.Properties, "commentId")
assert.Contains(t, schema.Properties, "body")
assert.Contains(t, schema.Properties, "reaction")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "commentId"})
// Setup mock reply comment for success case
mockReplyComment := &github.PullRequestComment{
ID: github.Ptr(int64(456)),
Body: github.Ptr("This is a reply to the comment"),
InReplyTo: github.Ptr(int64(123)),
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"),
User: &github.User{
Login: github.Ptr("responder"),
},
CreatedAt: &github.Timestamp{Time: time.Now()},
UpdatedAt: &github.Timestamp{Time: time.Now()},
}
mockReaction := &github.Reaction{
ID: github.Ptr(int64(789)),
Content: github.Ptr("rocket"),
}
replyCreatedAfterReactionFailure := &atomic.Bool{}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectToolError bool
expectedToolErrMsg string
unexpectedCall *atomic.Bool
}{
{
name: "successful reply to pull request comment",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
},
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
responseData, _ := json.Marshal(mockReplyComment)
_, _ = w.Write(responseData)
},
}),
},
{
name: "missing required parameter owner",
requestArgs: map[string]any{
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: owner",
},
{
name: "missing required parameter repo",
requestArgs: map[string]any{
"owner": "owner",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: repo",
},
{
name: "missing required parameter pullNumber when replying",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"commentId": float64(123),
"body": "This is a reply to the comment",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: pullNumber",
},
{
name: "missing required parameter pullNumber when replying with reaction",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"commentId": float64(123),
"body": "This is a reply to the comment",
"reaction": "rocket",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: pullNumber",
},
{
name: "missing required parameter commentId",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"body": "This is a reply to the comment",
},
expectToolError: true,
expectedToolErrMsg: "missing required parameter: commentId",
},
{
name: "negative commentId",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"commentId": float64(-123),
"reaction": "rocket",
},
expectToolError: true,
expectedToolErrMsg: "commentId must be greater than 0",
},
{
name: "missing body and reaction",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
},
expectToolError: true,
expectedToolErrMsg: "at least one of body or reaction is required",
},
{
name: "successful reaction to pull request comment",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"commentId": float64(123),
"reaction": "rocket",
},
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction),
}),
},
{
name: "successful reply and reaction to pull request comment",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
"reaction": "rocket",
},
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
responseData, _ := json.Marshal(mockReplyComment)
_, _ = w.Write(responseData)
},
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: mockResponse(t, http.StatusCreated, mockReaction),
}),
},
{
name: "API error when adding reply",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
},
expectToolError: true,
expectedToolErrMsg: "failed to add reply to pull request comment",
},
{
name: "does not create reply when reaction fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PostReposPullsCommentsReactionsByOwnerByRepoByCommentID: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message": "server error"}`))
},
PostReposPullsCommentsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) {
replyCreatedAfterReactionFailure.Store(true)
w.WriteHeader(http.StatusCreated)
responseData, _ := json.Marshal(mockReplyComment)
_, _ = w.Write(responseData)
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"commentId": float64(123),
"body": "This is a reply to the comment",
"reaction": "rocket",
},
expectToolError: true,
expectedToolErrMsg: "failed to add reaction to pull request review comment",
unexpectedCall: replyCreatedAfterReactionFailure,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := mustNewGHClient(t, tc.mockedClient)
serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper)
deps := BaseDeps{
Client: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
if tc.expectToolError {
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedToolErrMsg)
if tc.unexpectedCall != nil {
assert.False(t, tc.unexpectedCall.Load())
}
return
}
// Parse the result and verify it's not an error
require.False(t, result.IsError)
textContent := getTextResult(t, result)
if _, ok := tc.requestArgs["body"]; ok {
assert.Contains(t, textContent.Text, "This is a reply to the comment")
}
if _, ok := tc.requestArgs["reaction"]; ok {
assert.Contains(t, textContent.Text, "789")
}
})
}
}
func TestResolveReviewThread(t *testing.T) {
t.Parallel()
tests := []struct {
name string
requestArgs map[string]any
mockedClient *http.Client
expectToolError bool
expectedToolErrMsg string
expectedResult string
}{
{
name: "successful resolve thread",
requestArgs: map[string]any{
"method": "resolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"threadId": "PRRT_kwDOTest123",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewMutationMatcher(
struct {
ResolveReviewThread struct {
Thread struct {
ID githubv4.ID
IsResolved githubv4.Boolean
}
} `graphql:"resolveReviewThread(input: $input)"`
}{},
githubv4.ResolveReviewThreadInput{
ThreadID: githubv4.ID("PRRT_kwDOTest123"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"resolveReviewThread": map[string]any{
"thread": map[string]any{
"id": "PRRT_kwDOTest123",
"isResolved": true,
},
},
}),
),
),
expectedResult: "review thread resolved successfully",
},
{
name: "successful unresolve thread",
requestArgs: map[string]any{
"method": "unresolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"threadId": "PRRT_kwDOTest123",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewMutationMatcher(
struct {
UnresolveReviewThread struct {
Thread struct {
ID githubv4.ID
IsResolved githubv4.Boolean
}
} `graphql:"unresolveReviewThread(input: $input)"`
}{},
githubv4.UnresolveReviewThreadInput{
ThreadID: githubv4.ID("PRRT_kwDOTest123"),
},
nil,
githubv4mock.DataResponse(map[string]any{
"unresolveReviewThread": map[string]any{
"thread": map[string]any{
"id": "PRRT_kwDOTest123",
"isResolved": false,
},
},
}),
),
),
expectedResult: "review thread unresolved successfully",
},
{
name: "empty threadId for resolve",
requestArgs: map[string]any{
"method": "resolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"threadId": "",
},
mockedClient: githubv4mock.NewMockedHTTPClient(),
expectToolError: true,
expectedToolErrMsg: "threadId is required",
},
{
name: "empty threadId for unresolve",
requestArgs: map[string]any{
"method": "unresolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"threadId": "",
},
mockedClient: githubv4mock.NewMockedHTTPClient(),
expectToolError: true,
expectedToolErrMsg: "threadId is required",
},
{
name: "omitted threadId for resolve",
requestArgs: map[string]any{
"method": "resolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
mockedClient: githubv4mock.NewMockedHTTPClient(),
expectToolError: true,
expectedToolErrMsg: "threadId is required",
},
{
name: "omitted threadId for unresolve",
requestArgs: map[string]any{
"method": "unresolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
},
mockedClient: githubv4mock.NewMockedHTTPClient(),
expectToolError: true,
expectedToolErrMsg: "threadId is required",
},
{
name: "thread not found",
requestArgs: map[string]any{
"method": "resolve_thread",
"owner": "owner",
"repo": "repo",
"pullNumber": float64(42),
"threadId": "PRRT_invalid",
},
mockedClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewMutationMatcher(
struct {
ResolveReviewThread struct {
Thread struct {
ID githubv4.ID
IsResolved githubv4.Boolean
}
} `graphql:"resolveReviewThread(input: $input)"`
}{},
githubv4.ResolveReviewThreadInput{
ThreadID: githubv4.ID("PRRT_invalid"),
},
nil,
githubv4mock.ErrorResponse("Could not resolve to a PullRequestReviewThread with the id of 'PRRT_invalid'"),
),
),
expectToolError: true,
expectedToolErrMsg: "Could not resolve to a PullRequestReviewThread",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// Setup client with mock
client := githubv4.NewClient(tc.mockedClient)
serverTool := PullRequestReviewWrite(translations.NullTranslationHelper)
deps := BaseDeps{
GQLClient: client,
}
handler := serverTool.Handler(deps)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
textContent := getTextResult(t, result)
if tc.expectToolError {
require.True(t, result.IsError)
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
return
}
require.False(t, result.IsError)
assert.Equal(t, tc.expectedResult, textContent.Text)
})
}
}