Files
2026-07-13 12:37:57 +08:00

585 lines
20 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"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"
)
// recorderTransport routes HTTP requests through an in-process handler, mirroring
// internal/githubv4mock's own transport. We need it because githubv4mock keys its
// matchers by query string, so it cannot model a multi-page labels query: every
// page issues the identical query and differs only by the $cursor variable. This
// transport lets a single handler answer each page dynamically.
type recorderTransport struct{ handler http.Handler }
func (rt recorderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
rec := httptest.NewRecorder()
rt.handler.ServeHTTP(rec, req)
return rec.Result(), nil
}
// alwaysHasNextPageLabelsClient returns a GraphQL client whose labels query always
// reports another page, advancing the cursor on each call. It exercises uiGetLabels'
// page cap: the loop fetches one label per page until it stops at uiGetMaxPages with
// has_more=true. totalCount is reported as a large server-side count so the test can
// confirm it stays the full repo count even when results are truncated.
func alwaysHasNextPageLabelsClient(t *testing.T) *http.Client {
t.Helper()
var calls int
mux := http.NewServeMux()
mux.HandleFunc("/graphql", func(w http.ResponseWriter, _ *http.Request) {
calls++
resp := map[string]any{
"data": map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": fmt.Sprintf("label-%d", calls),
"name": fmt.Sprintf("label-%d", calls),
"color": "ededed",
"description": "",
},
},
"totalCount": 9999,
"pageInfo": map[string]any{
"hasNextPage": true,
"endCursor": fmt.Sprintf("cursor-%d", calls),
},
},
},
},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
})
return &http.Client{Transport: recorderTransport{handler: mux}}
}
// alwaysNextPageHandler returns a REST handler that always advertises another page
// via the Link header, regardless of the page requested. It drives a pagination loop
// purely off the page cap so tests can assert ui_get stops at uiGetMaxPages and sets
// has_more=true. The same body is returned for every page, so the number of items
// collected equals the number of pages fetched.
func alwaysNextPageHandler(t *testing.T, body any) http.HandlerFunc {
t.Helper()
return func(w http.ResponseWriter, r *http.Request) {
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil {
page = parsed
}
}
w.Header().Set("Link", fmt.Sprintf(`<https://api.github.com/next?page=%d>; rel="next"`, page+1))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(body)
}
}
func Test_UIGet(t *testing.T) {
// Verify tool definition
serverTool := UIGet(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "ui_get", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"})
assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only")
assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag")
// ui_get must be app-only so the host hides it from the agent's tool list
// while keeping it callable by the views (MCP Apps 2026-01-26 spec).
ui, ok := tool.Meta["ui"].(map[string]any)
require.True(t, ok, "ui_get should declare _meta.ui")
assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only")
// Setup mock data
mockAssignees := []*github.User{
{Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")},
{Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")},
}
mockBranches := []*github.Branch{
{Name: github.Ptr("main"), Protected: github.Ptr(true)},
{Name: github.Ptr("feature"), Protected: github.Ptr(false)},
}
dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
mockMilestones := []*github.Milestone{
{Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}},
{Number: github.Ptr(2), Title: github.Ptr("no due date")},
}
mockIssueTypes := []*github.IssueType{
{Name: github.Ptr("Bug")},
{Name: github.Ptr("Feature")},
}
mockReviewers := []*github.User{
{Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")},
{Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")},
{Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")},
}
mockReviewerTeams := []*github.Team{
{Slug: github.Ptr("docs"), Name: github.Ptr("Docs")},
}
tests := []struct {
name string
mockedClient *http.Client
mockedGQLClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
validateResult func(t *testing.T, responseText string)
}{
{
name: "successful assignees fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees),
}),
requestArgs: map[string]any{
"method": "assignees",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
assert.Contains(t, response, "assignees")
assert.Contains(t, response, "totalCount")
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},
},
{
name: "successful branches fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches),
}),
requestArgs: map[string]any{
"method": "branches",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
assert.Contains(t, response, "branches")
assert.Contains(t, response, "totalCount")
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},
},
{
name: "successful milestones fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones),
}),
requestArgs: map[string]any{
"method": "milestones",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
milestones, ok := response["milestones"].([]any)
require.True(t, ok, "milestones should be a list")
require.Len(t, milestones, 2)
first := milestones[0].(map[string]any)
assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted")
second := milestones[1].(map[string]any)
assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time")
},
},
{
name: "successful issue_types fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes),
}),
requestArgs: map[string]any{
"method": "issue_types",
"owner": "owner",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var issueTypes []map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes))
require.Len(t, issueTypes, 2)
assert.Equal(t, "Bug", issueTypes[0]["name"])
},
},
{
name: "issue_types API error returns response context",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}),
}),
requestArgs: map[string]any{
"method": "issue_types",
"owner": "owner",
},
expectError: true,
expectedErrMsg: "failed to list issue types",
},
{
name: "successful labels fetch",
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
struct {
Repository struct {
Labels struct {
Nodes []struct {
ID githubv4.ID
Name githubv4.String
Color githubv4.String
Description githubv4.String
}
TotalCount githubv4.Int
PageInfo struct {
HasNextPage githubv4.Boolean
EndCursor githubv4.String
}
} `graphql:"labels(first: 100, after: $cursor)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
"cursor": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"labels": map[string]any{
"nodes": []any{
map[string]any{
"id": githubv4.ID("label-1"),
"name": githubv4.String("bug"),
"color": githubv4.String("d73a4a"),
"description": githubv4.String("Something isn't working"),
},
},
"totalCount": githubv4.Int(1),
"pageInfo": map[string]any{
"hasNextPage": githubv4.Boolean(false),
"endCursor": githubv4.String(""),
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "labels",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
labels, ok := response["labels"].([]any)
require.True(t, ok, "labels should be a list")
require.Len(t, labels, 1)
assert.Equal(t, "bug", labels[0].(map[string]any)["name"])
assert.Equal(t, float64(1), response["totalCount"])
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},
},
{
name: "successful issue_fields fetch",
mockedGQLClient: githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
issueFieldsRepoQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"name": githubv4.String("repo"),
},
githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issueFields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldText",
"id": "IFT_1",
"fullDatabaseId": "42",
"name": "DRI",
"description": "Directly responsible individual",
"dataType": "text",
"visibility": "ORG_ONLY",
},
},
},
},
}),
),
),
requestArgs: map[string]any{
"method": "issue_fields",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
fields, ok := response["fields"].([]any)
require.True(t, ok, "fields should be a list")
require.Len(t, fields, 1)
assert.Equal(t, "DRI", fields[0].(map[string]any)["name"])
assert.Equal(t, "text", fields[0].(map[string]any)["data_type"])
assert.Equal(t, float64(1), response["totalCount"])
},
},
{
name: "successful reviewers fetch",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers),
"GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams),
}),
requestArgs: map[string]any{
"method": "reviewers",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
users, ok := response["users"].([]any)
require.True(t, ok, "users should be a list")
require.Len(t, users, 1)
assert.Equal(t, "octocat", users[0].(map[string]any)["login"])
teams, ok := response["teams"].([]any)
require.True(t, ok, "teams should be a list")
require.Len(t, teams, 1)
assert.Equal(t, "docs", teams[0].(map[string]any)["slug"])
assert.Equal(t, "owner", teams[0].(map[string]any)["org"])
assert.Equal(t, float64(2), response["totalCount"])
assert.Equal(t, false, response["has_more"], "results within the page cap should not be truncated")
},
},
{
name: "branches pagination stops at the page cap",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: github.Ptr("feature")}}),
}),
requestArgs: map[string]any{
"method": "branches",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
branches, ok := response["branches"].([]any)
require.True(t, ok, "branches should be a list")
assert.Len(t, branches, uiGetMaxPages, "loop should stop at the page cap")
assert.Equal(t, float64(uiGetMaxPages), response["totalCount"], "totalCount should be the bounded count")
assert.Equal(t, true, response["has_more"], "truncated results should set has_more")
},
},
{
name: "labels pagination stops at the page cap",
mockedGQLClient: alwaysHasNextPageLabelsClient(t),
requestArgs: map[string]any{
"method": "labels",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
labels, ok := response["labels"].([]any)
require.True(t, ok, "labels should be a list")
assert.Len(t, labels, uiGetMaxPages, "loop should stop at the page cap")
assert.Equal(t, true, response["has_more"], "truncated results should set has_more")
// totalCount stays the server-reported full count, so it can exceed
// the number of labels returned once results are truncated.
assert.Equal(t, float64(9999), response["totalCount"])
},
},
{
name: "reviewers pagination stops at the page cap",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: github.Ptr("octocat")}}),
"GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams),
}),
requestArgs: map[string]any{
"method": "reviewers",
"owner": "owner",
"repo": "repo",
},
expectError: false,
validateResult: func(t *testing.T, responseText string) {
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(responseText), &response))
users, ok := response["users"].([]any)
require.True(t, ok, "users should be a list")
assert.Len(t, users, uiGetMaxPages, "collaborators loop should stop at the page cap")
assert.Equal(t, true, response["has_more"], "truncating either loop should set has_more")
},
},
{
name: "missing method parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "missing required parameter: method",
},
{
name: "missing owner parameter",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "assignees",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing repo parameter for assignees",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "assignees",
"owner": "owner",
},
expectError: true,
expectedErrMsg: "missing required parameter: repo",
},
{
name: "unknown method",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"method": "unknown",
"owner": "owner",
"repo": "repo",
},
expectError: true,
expectedErrMsg: "unknown method: unknown",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup deps with REST and/or GraphQL mocks
deps := BaseDeps{}
if tc.mockedClient != nil {
client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient))
require.NoError(t, err)
deps.Client = client
}
if tc.mockedGQLClient != nil {
deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient)
}
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
}
require.NotNil(t, result)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, result.IsError)
textContent := getTextResult(t, result)
if tc.validateResult != nil {
tc.validateResult(t, textContent.Text)
}
})
}
}
func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) {
priorityLow := 1
priorityHigh := 2
result, _, err := marshalUIGetIssueFields([]IssueField{
{
ID: "field-1",
DatabaseID: 123,
Name: "Priority",
Description: "How urgent this is",
DataType: "single_select",
Visibility: "public",
Options: []IssueSingleSelectFieldOption{
{ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh},
{ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow},
{ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"},
},
},
{
ID: "field-2",
Name: "Unsupported",
DataType: "iteration",
},
{
ID: "field-3",
Name: "Notes",
DataType: "text",
},
})
require.NoError(t, err)
var response map[string]any
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response))
fields := response["fields"].([]any)
require.Len(t, fields, 2)
assert.Equal(t, float64(2), response["totalCount"])
singleSelectField := fields[0].(map[string]any)
assert.NotContains(t, singleSelectField, "full_database_id")
assert.NotContains(t, singleSelectField, "visibility")
options := singleSelectField["options"].([]any)
require.Len(t, options, 3)
assert.Equal(t, "Low", options[0].(map[string]any)["name"])
assert.Equal(t, "High", options[1].(map[string]any)["name"])
assert.Equal(t, "No priority", options[2].(map[string]any)["name"])
assert.NotContains(t, options[0].(map[string]any), "id")
assert.NotContains(t, options[0].(map[string]any), "priority")
textField := fields[1].(map[string]any)
assert.NotContains(t, textField, "options")
}