package github import ( "context" "encoding/json" "testing" "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/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func Test_ListIssueFields(t *testing.T) { // Verify tool definition serverTool := ListIssueFields(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "list_issue_fields", tool.Name) assert.NotEmpty(t, tool.Description) assert.True(t, tool.Annotations.ReadOnlyHint) 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{"owner"}) assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"}) assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"}) queryStruct := issueFieldsRepoQuery{} defaultVars := map[string]any{ "owner": githubv4.String("testowner"), "name": githubv4.String("testrepo"), } orgQueryStruct := issueFieldsOrgQuery{} defaultOrgVars := map[string]any{ "login": githubv4.String("testowner"), } tests := []struct { name string requestArgs map[string]any mockQueryStruct any mockVars map[string]any gqlResponse githubv4mock.GQLResponse expectError bool expectedFields []IssueField expectedErrMsg string }{ { name: "no fields returns empty list", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ "nodes": []any{}, }, }, }), expectedFields: []IssueField{}, }, { name: "text field returned", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: 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", }, }, }, }, }), expectedFields: []IssueField{ { ID: "IFT_1", DatabaseID: 42, Name: "DRI", Description: "Directly responsible individual", DataType: "TEXT", Visibility: "ORG_ONLY", }, }, }, { name: "single_select field with options returned", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ "nodes": []any{ map[string]any{ "__typename": "IssueFieldSingleSelect", "id": "IFSS_1", "fullDatabaseId": "99", "name": "Priority", "description": "Level of importance", "dataType": "SINGLE_SELECT", "visibility": "ALL", "options": []any{ map[string]any{ "id": "OPT_1", "name": "High", "color": "red", }, map[string]any{ "id": "OPT_2", "name": "Low", "color": "blue", }, }, }, }, }, }, }), expectedFields: []IssueField{ { ID: "IFSS_1", DatabaseID: 99, Name: "Priority", Description: "Level of importance", DataType: "SINGLE_SELECT", Visibility: "ALL", Options: []IssueSingleSelectFieldOption{ {ID: "OPT_1", Name: "High", Color: "red"}, {ID: "OPT_2", Name: "Low", Color: "blue"}, }, }, }, }, { name: "missing owner parameter", requestArgs: map[string]any{ "repo": "testrepo", }, gqlResponse: githubv4mock.DataResponse(map[string]any{}), expectError: true, expectedErrMsg: "missing required parameter: owner", }, { name: "no repo returns org-level fields", requestArgs: map[string]any{ "owner": "testowner", }, mockQueryStruct: orgQueryStruct, mockVars: defaultOrgVars, gqlResponse: githubv4mock.DataResponse(map[string]any{ "organization": map[string]any{ "issueFields": map[string]any{ "nodes": []any{ map[string]any{ "__typename": "IssueFieldText", "id": "IFT_1", "fullDatabaseId": "77", "name": "DRI", "dataType": "TEXT", "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ {ID: "IFT_1", DatabaseID: 77, Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, }, }, { name: "number field returned", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ "nodes": []any{ map[string]any{ "__typename": "IssueFieldNumber", "id": "IFN_1", "fullDatabaseId": "101", "name": "Engineering Staffing", "dataType": "NUMBER", "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ {ID: "IFN_1", DatabaseID: 101, Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, }, }, { name: "date field returned", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ "nodes": []any{ map[string]any{ "__typename": "IssueFieldDate", "id": "IFD_1", "fullDatabaseId": "202", "name": "Target Date", "dataType": "DATE", "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ {ID: "IFD_1", DatabaseID: 202, Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, }, }, { name: "graphql error returns failure", requestArgs: map[string]any{ "owner": "testowner", "repo": "testrepo", }, gqlResponse: githubv4mock.ErrorResponse("boom"), expectError: true, expectedErrMsg: "failed to list issue fields", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { qs := tc.mockQueryStruct if qs == nil { qs = queryStruct } vars := tc.mockVars if vars == nil { vars = defaultVars } mockedHTTPClient := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse), ) gqlClient := githubv4.NewClient(mockedHTTPClient) deps := BaseDeps{GQLClient: gqlClient} handler := serverTool.Handler(deps) request := createMCPRequest(tc.requestArgs) result, err := handler(ContextWithDeps(context.Background(), deps), &request) 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) var returnedFields []IssueField err = json.Unmarshal([]byte(textContent.Text), &returnedFields) require.NoError(t, err) require.Equal(t, len(tc.expectedFields), len(returnedFields)) for i, expected := range tc.expectedFields { assert.Equal(t, expected.ID, returnedFields[i].ID) assert.Equal(t, expected.DatabaseID, returnedFields[i].DatabaseID) assert.Equal(t, expected.Name, returnedFields[i].Name) assert.Equal(t, expected.DataType, returnedFields[i].DataType) assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) if expected.Options != nil { require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) for j, opt := range expected.Options { assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color) } } } }) } }