diff --git a/README.md b/README.md index 9a96cfd04..3b0cd861f 100644 --- a/README.md +++ b/README.md @@ -658,10 +658,19 @@ The following sets of tools are available (all are on by default): Projects +- **get_project** - Get project + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `project_number`: The project's number (number, required) + +- **list_project_fields** - List project fields + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `per_page`: Number of results per page (max 100, default: 30) (number, optional) + - `projectNumber`: The project's number. (string, required) + - **list_projects** - List projects - - `after`: Cursor for items after (forward pagination) (string, optional) - - `before`: Cursor for items before (backwards pagination) (string, optional) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive. (string, required) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `per_page`: Number of results per page (max 100, default: 30) (number, optional) - `query`: Filter projects by a search query (matches title and description) (string, optional) diff --git a/pkg/github/__toolsnaps__/get_project.snap b/pkg/github/__toolsnaps__/get_project.snap new file mode 100644 index 000000000..db060e427 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project.snap @@ -0,0 +1,34 @@ +{ + "annotations": { + "title": "Get project", + "readOnlyHint": true + }, + "description": "Get Project for a user or org", + "inputSchema": { + "properties": { + "owner": { + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "project_number": { + "description": "The project's number", + "type": "number" + } + }, + "required": [ + "project_number", + "owner_type", + "owner" + ], + "type": "object" + }, + "name": "get_project" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap new file mode 100644 index 000000000..3a293463e --- /dev/null +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -0,0 +1,38 @@ +{ + "annotations": { + "title": "List project fields", + "readOnlyHint": true + }, + "description": "List Project fields for a user or org", + "inputSchema": { + "properties": { + "owner": { + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Number of results per page (max 100, default: 30)", + "type": "number" + }, + "projectNumber": { + "description": "The project's number.", + "type": "string" + } + }, + "required": [ + "owner_type", + "owner", + "projectNumber" + ], + "type": "object" + }, + "name": "list_project_fields" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_projects.snap b/pkg/github/__toolsnaps__/list_projects.snap index d0fefe0bc..8de28989a 100644 --- a/pkg/github/__toolsnaps__/list_projects.snap +++ b/pkg/github/__toolsnaps__/list_projects.snap @@ -3,26 +3,18 @@ "title": "List projects", "readOnlyHint": true }, - "description": "List Projects for a user or organization", + "description": "List Projects for a user or org", "inputSchema": { "properties": { - "after": { - "description": "Cursor for items after (forward pagination)", - "type": "string" - }, - "before": { - "description": "Cursor for items before (backwards pagination)", - "type": "string" - }, "owner": { - "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive.", + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", "type": "string" }, "owner_type": { "description": "Owner type", "enum": [ "user", - "organization" + "org" ], "type": "string" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 23ee91459..d4ab48844 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -19,13 +19,11 @@ import ( func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_projects", - mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or organization")), + mcp.WithDescription(t("TOOL_LIST_PROJECTS_DESCRIPTION", "List Projects for a user or org")), mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), ReadOnlyHint: ToBoolPtr(true)}), - mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "organization")), - mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == organization it is the name of the organization. The name is not case sensitive.")), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")), mcp.WithString("query", mcp.Description("Filter projects by a search query (matches title and description)")), - mcp.WithString("before", mcp.Description("Cursor for items before (backwards pagination)")), - mcp.WithString("after", mcp.Description("Cursor for items after (forward pagination)")), mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -40,16 +38,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( if err != nil { return mcp.NewToolResultError(err.Error()), nil } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } - beforeCursor, err := OptionalParam[string](req, "before") + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2", owner) + } else { + url = fmt.Sprintf("users/%s/projectsV2", owner) + } + projects := []github.ProjectV2{} + + opts := listProjectsOptions{PerPage: perPage} + + if queryStr != "" { + opts.Query = queryStr + } + if perPage > 0 { + opts.PerPage = perPage + } + url, err = addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, &projects) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil + } + r, err := json.Marshal(projects) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project", + mcp.WithDescription(t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number")), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - afterCursor, err := OptionalParam[string](req, "after") + + owner, err := RequiredParam[string](req, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + + ownerType, err := RequiredParam[string](req, "owner_type") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -60,22 +129,87 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } var url string - if ownerType == "organization" { - url = fmt.Sprintf("/orgs/%s/projectsV2", owner) + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%d", owner, projectNumber) } else { - url = fmt.Sprintf("/users/%s/projectsV2", owner) + url = fmt.Sprintf("users/%s/projectsV2/%d", owner, projectNumber) } - projects := []github.ProjectV2{} - opts := ListProjectsOptions{PerPage: perPage} - if afterCursor != "" { - opts.After = afterCursor + project := github.ProjectV2{} + + httpRequest, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) } - if beforeCursor != "" { - opts.Before = beforeCursor + + resp, err := client.Do(ctx, httpRequest, &project) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + resp, + err, + ), nil } - if queryStr != "" { - opts.Query = queryStr + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil + } + r, err := json.Marshal(project) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_project_fields", + mcp.WithDescription(t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}), + mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")), + mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")), + mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), + ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](req, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + ownerType, err := RequiredParam[string](req, "owner_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projectNumber, err := RequiredParam[string](req, "projectNumber") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var url string + if ownerType == "org" { + url = fmt.Sprintf("orgs/%s/projectsV2/%s/fields", owner, projectNumber) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%s/fields", owner, projectNumber) + } + projectFields := []projectV2Field{} + + opts := listProjectsOptions{PerPage: perPage} + + if perPage > 0 { + opts.PerPage = perPage } url, err = addOptions(url, opts) if err != nil { @@ -87,7 +221,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( return nil, fmt.Errorf("failed to create request: %w", err) } - resp, err := client.Do(ctx, httpRequest, &projects) + resp, err := client.Do(ctx, httpRequest, &projectFields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list projects", @@ -104,7 +238,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil } - r, err := json.Marshal(projects) + r, err := json.Marshal(projectFields) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -113,13 +247,18 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } } -type ListProjectsOptions struct { - // A cursor, as given in the Link header. If specified, the query only searches for events before this cursor. - Before string `url:"before,omitempty"` - - // A cursor, as given in the Link header. If specified, the query only searches for events after this cursor. - After string `url:"after,omitempty"` +type projectV2Field struct { + ID *int64 `json:"id,omitempty"` // The unique identifier for this field. + NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field. + Name string `json:"name,omitempty"` // The display name of the field. + DataType string `json:"dataType,omitempty"` // The data type of the field (e.g., "text", "number", "date", "single_select", "multi_select"). + URL string `json:"url,omitempty"` // The API URL for this field. + Options []*any `json:"options,omitempty"` // Available options for single_select and multi_select fields. + CreatedAt *github.Timestamp `json:"created_at,omitempty"` // The time when this field was created. + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated. +} +type listProjectsOptions struct { // For paginated result sets, the number of results to include per page. PerPage int `url:"per_page,omitempty"` diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 3f779a17b..1ea19d18b 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -15,7 +15,6 @@ import ( ) func Test_ListProjects(t *testing.T) { - // Verify tool definition and schema once mockClient := gh.NewClient(nil) tool, _ := ListProjects(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) @@ -25,12 +24,9 @@ func Test_ListProjects(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "owner_type") assert.Contains(t, tool.InputSchema.Properties, "query") - assert.Contains(t, tool.InputSchema.Properties, "before") - assert.Contains(t, tool.InputSchema.Properties, "after") assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "owner_type"}) - // Minimal project objects (fields chosen to likely exist on ProjectV2; test only asserts round-trip JSON array length) orgProjects := []map[string]any{{"id": 1, "title": "Org Project"}} userProjects := []map[string]any{{"id": 2, "title": "User Project"}} @@ -52,7 +48,7 @@ func Test_ListProjects(t *testing.T) { ), requestArgs: map[string]interface{}{ "owner": "octo-org", - "owner_type": "organization", + "owner_type": "org", }, expectError: false, expectedLength: 1, @@ -79,8 +75,7 @@ func Test_ListProjects(t *testing.T) { mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() - // Assert query params present - if q.Get("after") == "cursor123" && q.Get("per_page") == "50" && q.Get("q") == "roadmap" { + if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { w.WriteHeader(http.StatusOK) _, _ = w.Write(mock.MustMarshal(orgProjects)) return @@ -92,8 +87,7 @@ func Test_ListProjects(t *testing.T) { ), requestArgs: map[string]interface{}{ "owner": "octo-org", - "owner_type": "organization", - "after": "cursor123", + "owner_type": "org", "per_page": float64(50), "query": "roadmap", }, @@ -110,7 +104,7 @@ func Test_ListProjects(t *testing.T) { ), requestArgs: map[string]interface{}{ "owner": "octo-org", - "owner_type": "organization", + "owner_type": "org", }, expectError: true, expectedErrMsg: "failed to list projects", @@ -119,7 +113,7 @@ func Test_ListProjects(t *testing.T) { name: "missing owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner_type": "organization", + "owner_type": "org", }, expectError: true, }, @@ -147,7 +141,6 @@ func Test_ListProjects(t *testing.T) { if tc.expectedErrMsg != "" { assert.Contains(t, text, tc.expectedErrMsg) } - // Parameter missing cases if tc.name == "missing owner" { assert.Contains(t, text, "missing required parameter: owner") } @@ -166,3 +159,283 @@ func Test_ListProjects(t *testing.T) { }) } } + +func Test_GetProject(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProject(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"project_number", "owner", "owner_type"}) + + project := map[string]any{"id": 123, "title": "Project Title"} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "success organization project fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, project), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + }, + { + name: "success user project fetch", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, project), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(456), + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "project_number": float64(999), + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "failed to get project", + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner_type": "org", + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "project_number": float64(123), + "owner": "octo-org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProject(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var arr map[string]any + err = json.Unmarshal([]byte(textContent.Text), &arr) + require.NoError(t, err) + }) + } +} + +func Test_ListProjectFields(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjectFields(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner_type") + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "projectNumber") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber"}) + + orgFields := []map[string]any{ + {"id": 101, "name": "Status", "dataType": "single_select"}, + } + userFields := []map[string]any{ + {"id": 201, "name": "Priority", "dataType": "single_select"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgFields), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "projectNumber": "123", + }, + expectedLength: 1, + }, + { + name: "success user fields with per_page override", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userFields)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + "projectNumber": "456", + "per_page": float64(50), + }, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "projectNumber": "789", + }, + expectError: true, + expectedErrMsg: "failed to list projects", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + "projectNumber": "10", + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "projectNumber": "10", + }, + expectError: true, + }, + { + name: "missing projectNumber", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjectFields(stubGetClientFn(client), translations.NullTranslationHelper) + request := createMCPRequest(tc.requestArgs) + result, err := handler(context.Background(), request) + + require.NoError(t, err) + if tc.expectError { + require.True(t, result.IsError) + text := getTextResult(t, result).Text + if tc.expectedErrMsg != "" { + assert.Contains(t, text, tc.expectedErrMsg) + } + if tc.name == "missing owner" { + assert.Contains(t, text, "missing required parameter: owner") + } + if tc.name == "missing owner_type" { + assert.Contains(t, text, "missing required parameter: owner_type") + } + if tc.name == "missing projectNumber" { + assert.Contains(t, text, "missing required parameter: projectNumber") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var fields []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &fields) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(fields)) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7fb5332aa..2de9c23ca 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -193,6 +193,8 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG projects := toolsets.NewToolset("projects", "GitHub Projects related tools"). AddReadTools( toolsets.NewServerTool(ListProjects(getClient, t)), + toolsets.NewServerTool(GetProject(getClient, t)), + toolsets.NewServerTool(ListProjectFields(getClient, t)), ) // Add toolsets to the group