diff --git a/README.md b/README.md index ff3e44ba9..bd0963abf 100644 --- a/README.md +++ b/README.md @@ -658,6 +658,19 @@ The following sets of tools are available (all are on by default): Projects +- **add_project_item** - Add project item + - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) + - `item_type`: The item's type, either issue or pull_request. (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) + - `project_number`: The project's number. (number, required) + +- **delete_project_item** - Delete project item + - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, 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) + - `project_number`: The project's number. (number, required) + - **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) @@ -694,6 +707,13 @@ The following sets of tools are available (all are on by default): - `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) +- **update_project_item** - Update project item + - `fields`: A list of field updates to apply. (array, required) + - `item_id`: The numeric ID of the project item to update (not the issue or pull request ID). (number, 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) + - `project_number`: The project's number. (number, required) +
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9de0682f3..34e374a24 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -118,8 +118,8 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { // Generate instructions based on enabled toolsets instructions := github.GenerateInstructions(enabledToolsets) - - ghServer := github.NewServer(cfg.Version, + + ghServer := github.NewServer(cfg.Version, server.WithInstructions(instructions), server.WithHooks(hooks), ) diff --git a/pkg/github/__toolsnaps__/add_project_item.snap b/pkg/github/__toolsnaps__/add_project_item.snap new file mode 100644 index 000000000..143c04eb9 --- /dev/null +++ b/pkg/github/__toolsnaps__/add_project_item.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "title": "Add project item", + "readOnlyHint": false + }, + "description": "Add a specific Project item for a user or org", + "inputSchema": { + "properties": { + "item_id": { + "description": "The numeric ID of the issue or pull request to add to the project.", + "type": "number" + }, + "item_type": { + "description": "The item's type, either issue or pull_request.", + "enum": [ + "issue", + "pull_request" + ], + "type": "string" + }, + "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": [ + "owner_type", + "owner", + "project_number", + "item_type", + "item_id" + ], + "type": "object" + }, + "name": "add_project_item" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_project_item.snap b/pkg/github/__toolsnaps__/delete_project_item.snap new file mode 100644 index 000000000..0de1336a0 --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_project_item.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "title": "Delete project item", + "readOnlyHint": false + }, + "description": "Delete a specific Project item for a user or org", + "inputSchema": { + "properties": { + "item_id": { + "description": "The internal project item ID to delete from the project (not the issue or pull request ID).", + "type": "number" + }, + "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": [ + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" + }, + "name": "delete_project_item" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap new file mode 100644 index 000000000..ff2905282 --- /dev/null +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -0,0 +1,44 @@ +{ + "annotations": { + "title": "Update project item", + "readOnlyHint": false + }, + "description": "Update a specific Project item for a user or org", + "inputSchema": { + "properties": { + "fields": { + "description": "A list of field updates to apply.", + "type": "array" + }, + "item_id": { + "description": "The numeric ID of the project item to update (not the issue or pull request ID).", + "type": "number" + }, + "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": [ + "owner_type", + "owner", + "project_number", + "item_id", + "fields" + ], + "type": "object" + }, + "name": "update_project_item" +} \ No newline at end of file diff --git a/pkg/github/instructions.go b/pkg/github/instructions.go index 3f72e707b..7eefe53f0 100644 --- a/pkg/github/instructions.go +++ b/pkg/github/instructions.go @@ -12,21 +12,21 @@ func GenerateInstructions(enabledToolsets []string) string { if os.Getenv("DISABLE_INSTRUCTIONS") == "true" { return "" // Baseline mode } - + var instructions []string - + // Core instruction - always included if context toolset enabled if slices.Contains(enabledToolsets, "context") { instructions = append(instructions, "Always call 'get_me' first to understand current user permissions and context.") } - + // Individual toolset instructions for _, toolset := range enabledToolsets { if inst := getToolsetInstructions(toolset); inst != "" { instructions = append(instructions, inst) } } - + // Base instruction with context management baseInstruction := `The GitHub MCP Server provides tools to interact with GitHub platform. @@ -40,7 +40,7 @@ Context management: allInstructions := []string{baseInstruction} allInstructions = append(allInstructions, instructions...) - + return strings.Join(allInstructions, " ") } @@ -57,4 +57,3 @@ func getToolsetInstructions(toolset string) string { return "" } } - diff --git a/pkg/github/instructions_test.go b/pkg/github/instructions_test.go index 8450dc1a1..f00e0ac74 100644 --- a/pkg/github/instructions_test.go +++ b/pkg/github/instructions_test.go @@ -163,4 +163,4 @@ func TestGetToolsetInstructions(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f3ea0f7e1..09bcbd5ed 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "reflect" + "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/translations" @@ -474,6 +475,289 @@ func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } } +func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("add_project_item", + mcp.WithDescription(t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), ReadOnlyHint: ToBoolPtr(false)}), + 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithString("item_type", mcp.Required(), mcp.Description("The item's type, either issue or pull_request."), mcp.Enum("issue", "pull_request")), + mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The numeric ID of the issue or pull request to add to the project.")), + ), 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + itemType, err := RequiredParam[string](req, "item_type") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if itemType != "issue" && itemType != "pull_request" { + return mcp.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil + } + + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items", owner, projectNumber) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) + } + + newProjectItem := &newProjectItem{ + ID: int64(itemID), + Type: toNewProjectType(itemType), + } + httpRequest, err := client.NewRequest("POST", projectsURL, newProjectItem) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + addedItem := projectV2Item{} + + resp, err := client.Do(ctx, httpRequest, &addedItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to add a project item", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated && 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 add a project item: %s", string(body))), nil + } + r, err := json.Marshal(convertToMinimalProjectItem(&addedItem)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("delete_project_item", + mcp.WithDescription(t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), ReadOnlyHint: ToBoolPtr(false)}), + 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The internal project item ID to delete from the project (not the issue or pull request ID).")), + ), 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + + httpRequest, err := client.NewRequest("DELETE", projectsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := client.Do(ctx, httpRequest, nil) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to delete a project item", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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 delete a project item: %s", string(body))), nil + } + return mcp.NewToolResultText("project item successfully deleted"), nil + } +} + +func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("update_project_item", + mcp.WithDescription(t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), ReadOnlyHint: ToBoolPtr(false)}), + 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The numeric ID of the project item to update (not the issue or pull request ID).")), + mcp.WithArray("fields", mcp.Required(), mcp.Description("A list of field updates to apply.")), + ), 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldsParam, ok := req.GetArguments()["fields"] + if !ok { + return mcp.NewToolResultError("missing required parameter: fields"), nil + } + + rawFields, ok := fieldsParam.([]any) + if !ok { + return mcp.NewToolResultError("parameter fields must be an array of objects"), nil + } + if len(rawFields) == 0 { + return mcp.NewToolResultError("fields must contain at least one field update"), nil + } + + var projectsURL string + if ownerType == "org" { + projectsURL = fmt.Sprintf("orgs/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } else { + projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + + updateFields := make([]*newProjectV2Field, 0, len(rawFields)) + for idx, rawField := range rawFields { + fieldMap, ok := rawField.(map[string]any) + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("fields[%d] must be an object", idx)), nil + } + + rawID, ok := fieldMap["id"] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'id'", idx)), nil + } + + var fieldID int64 + switch v := rawID.(type) { + case float64: + fieldID = int64(v) + case int64: + fieldID = v + case json.Number: + n, convErr := v.Int64() + if convErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil + } + fieldID = n + default: + return mcp.NewToolResultError(fmt.Sprintf("fields[%d].id must be a numeric value", idx)), nil + } + + value, ok := fieldMap["value"] + if !ok { + return mcp.NewToolResultError(fmt.Sprintf("fields[%d] is missing 'value'", idx)), nil + } + + updateFields = append(updateFields, &newProjectV2Field{ + ID: github.Ptr(fieldID), + Value: value, + }) + } + + updateProjectItemOptions := &updateProjectItemOptions{Fields: updateFields} + + httpRequest, err := client.NewRequest("PATCH", projectsURL, updateProjectItemOptions) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + updatedItem := projectV2Item{} + resp, err := client.Do(ctx, httpRequest, &updatedItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to update a project item", + 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 update a project item: %s", string(body))), nil + } + r, err := json.Marshal(convertToMinimalProjectItem(&updatedItem)) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +type updateProjectItemOptions struct { + Fields []*newProjectV2Field `json:"fields,omitempty"` +} + +type newProjectV2Field struct { + ID *int64 `json:"id,omitempty"` + Value any `json:"value,omitempty"` +} + +type newProjectItem struct { + ID int64 `json:"id,omitempty"` // Issue or Pull Request ID to add to the project. + Type string `json:"type,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. @@ -500,6 +784,17 @@ type projectV2Item struct { Fields []*projectV2Field `json:"fields,omitempty"` } +func toNewProjectType(projType string) string { + switch strings.ToLower(projType) { + case "issue": + return "Issue" + case "pull_request": + return "PullRequest" + default: + return "" + } +} + 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 a6225ec86..628bad8fb 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "io" "net/http" "testing" @@ -928,3 +929,620 @@ func Test_GetProjectItem(t *testing.T) { }) } } + +func Test_AddProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := AddProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "add_project_item", 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_type") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_type", "item_id"}) + + orgItem := map[string]any{ + "id": 601, + "content_type": "Issue", + "creator": map[string]any{ + "login": "octocat", + "id": 1, + "html_url": "https://github.com/octocat", + "avatar_url": "https://avatars.githubusercontent.com/u/1?v=4", + }, + } + + userItem := map[string]any{ + "id": 701, + "content_type": "PullRequest", + "creator": map[string]any{ + "login": "hubot", + "id": 2, + "html_url": "https://github.com/hubot", + "avatar_url": "https://avatars.githubusercontent.com/u/2?v=4", + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + expectedContentType string + expectedCreatorLogin string + }{ + { + name: "success organization issue", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Type string `json:"type"` + ID int `json:"id"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "Issue", payload.Type) + assert.Equal(t, 9876, payload.ID) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(orgItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(321), + "item_type": "issue", + "item_id": float64(9876), + }, + expectedID: 601, + expectedContentType: "Issue", + expectedCreatorLogin: "octocat", + }, + { + name: "success user pull request", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Type string `json:"type"` + ID int `json:"id"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Equal(t, "PullRequest", payload.Type) + assert.Equal(t, 7654, payload.ID) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(222), + "item_type": "pull_request", + "item_id": float64(7654), + }, + expectedID: 701, + expectedContentType: "PullRequest", + expectedCreatorLogin: "hubot", + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(999), + "item_type": "issue", + "item_id": float64(8888), + }, + expectError: true, + expectedErrMsg: "failed to add a project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_type": "Issue", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_type": "Issue", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := AddProjectItem(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) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_type": + assert.Contains(t, text, "missing required parameter: item_type") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + if tc.expectedContentType != "" { + assert.Equal(t, tc.expectedContentType, item["content_type"]) + } + if tc.expectedCreatorLogin != "" { + creator, ok := item["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) + } + }) + } +} + +func Test_DeleteProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "delete_project_item", 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedText string + }{ + { + name: "success organization delete", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(555), + }, + expectedText: "project item successfully deleted", + }, + { + name: "success user delete", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "item_id": float64(777), + }, + expectedText: "project item successfully deleted", + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(321), + "item_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to delete a project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(10), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := DeleteProjectItem(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) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + text := getTextResult(t, result).Text + assert.Contains(t, text, tc.expectedText) + }) + } +} + +func Test_UpdateProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := UpdateProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "update_project_item", 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.Contains(t, tool.InputSchema.Properties, "fields") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "fields"}) + + orgUpdated := map[string]any{ + "id": 801, + "content_type": "Issue", + "creator": map[string]any{"login": "octocat"}, + } + userUpdated := map[string]any{ + "id": 901, + "content_type": "PullRequest", + "creator": map[string]any{"login": "hubot"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + expectedCreatorLogin string + }{ + { + name: "success organization update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload struct { + Fields []struct { + ID int `json:"id"` + Value interface{} `json:"value"` + } `json:"fields"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + assert.Len(t, payload.Fields, 1) + if len(payload.Fields) == 1 { + assert.Equal(t, 123, payload.Fields[0].ID) + assert.Equal(t, "In Progress", payload.Fields[0].Value) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgUpdated)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(111), + "item_id": float64(2222), + "fields": []any{ + map[string]any{"id": float64(123), "value": "In Progress"}, + }, + }, + expectedID: 801, + expectedCreatorLogin: "octocat", + }, + { + name: "success user update", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + assert.NoError(t, err) + var payload map[string]any + assert.NoError(t, json.Unmarshal(body, &payload)) + fields, ok := payload["fields"].([]any) + assert.True(t, ok) + assert.Len(t, fields, 1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userUpdated)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(222), + "item_id": float64(3333), + "fields": []any{ + map[string]any{"id": float64(456), "value": 42}, + }, + }, + expectedID: 901, + expectedCreatorLogin: "hubot", + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(333), + "item_id": float64(4444), + "fields": []any{ + map[string]any{"id": float64(789), "value": "Done"}, + }, + }, + expectError: true, + expectedErrMsg: "failed to update a project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1), + "fields": []any{map[string]any{"id": float64(1), "value": "X"}}, + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(1), + "item_id": float64(1), + "fields": []any{map[string]any{"id": float64(1), "value": "X"}}, + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(1), + "fields": []any{map[string]any{"id": float64(1), "value": "X"}}, + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "fields": []any{map[string]any{"id": float64(1), "value": "X"}}, + }, + expectError: true, + }, + { + name: "missing fields", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1), + }, + expectError: true, + expectedErrMsg: "missing required parameter: fields", + }, + { + name: "empty fields", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1), + "fields": []any{}, + }, + expectError: true, + expectedErrMsg: "fields must contain at least one field update", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := UpdateProjectItem(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) + } + switch tc.name { + case "missing owner": + assert.Contains(t, text, "missing required parameter: owner") + case "missing owner_type": + assert.Contains(t, text, "missing required parameter: owner_type") + case "missing project_number": + assert.Contains(t, text, "missing required parameter: project_number") + case "missing item_id": + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &item)) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + if tc.expectedCreatorLogin != "" { + creator, ok := item["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, tc.expectedCreatorLogin, creator["login"]) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index eb6e657e3..dec0a9e37 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -198,6 +198,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(GetProjectField(getClient, t)), toolsets.NewServerTool(ListProjectItems(getClient, t)), toolsets.NewServerTool(GetProjectItem(getClient, t)), + ). + AddWriteTools( + toolsets.NewServerTool(AddProjectItem(getClient, t)), + toolsets.NewServerTool(DeleteProjectItem(getClient, t)), + toolsets.NewServerTool(UpdateProjectItem(getClient, t)), ) // Add toolsets to the group