From 6d77cdeb8d9adef74eef4afd533c00d98456e7a9 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 8 Oct 2025 16:45:24 +0200 Subject: [PATCH 01/12] Add update project item tool --- pkg/github/minimal_types.go | 4 ++ pkg/github/projects.go | 127 +++++++++++++++++++++++++++++++++++- pkg/github/tools.go | 1 + 3 files changed, 131 insertions(+), 1 deletion(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 59cab6b43..766f630bb 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -134,6 +134,8 @@ type MinimalProject struct { type MinimalProjectItem struct { ID *int64 `json:"id,omitempty"` NodeID *string `json:"node_id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` ProjectNodeID *string `json:"project_node_id,omitempty"` ContentNodeID *string `json:"content_node_id,omitempty"` ProjectURL *string `json:"project_url,omitempty"` @@ -192,6 +194,8 @@ func convertToMinimalProjectItem(item *projectV2Item) *MinimalProjectItem { return &MinimalProjectItem{ ID: item.ID, NodeID: item.NodeID, + Title: item.Title, + Description: item.Description, ProjectNodeID: item.ProjectNodeID, ContentNodeID: item.ContentNodeID, ProjectURL: item.ProjectURL, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 95b859c03..5abfaf74b 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -558,6 +558,93 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) } } +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 issue or pull request to add to the project.")), + mcp.WithObject("new_field", mcp.Required(), mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"New Value\"}")), + ), 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 + } + + rawNewField, exists := req.GetArguments()["new_field"] + if !exists { + return mcp.NewToolResultError("missing required parameter: new_field"), nil + } + + newField, ok := rawNewField.(map[string]any) + if !ok || newField == nil { + return mcp.NewToolResultError("new_field must be an object"), nil + } + + updatePayload, err := buildUpdateProjectItem(newField) + 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("PATCH", projectsURL, updateProjectItemPayload{ + Fields: []updateProjectItem{*updatePayload}, + }) + 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")), @@ -622,10 +709,19 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu } type newProjectItem struct { - ID int64 `json:"id,omitempty"` // Issue or Pull Request ID to add to the project. + ID int64 `json:"id,omitempty"` Type string `json:"type,omitempty"` } +type updateProjectItemPayload struct { + Fields []updateProjectItem `json:"fields"` +} + +type updateProjectItem struct { + ID int `json:"id"` + Value any `json:"value"` +} + 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. @@ -639,6 +735,8 @@ type projectV2Field struct { type projectV2Item struct { ID *int64 `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` NodeID *string `json:"node_id,omitempty"` ProjectNodeID *string `json:"project_node_id,omitempty"` ContentNodeID *string `json:"content_node_id,omitempty"` @@ -671,6 +769,33 @@ type listProjectsOptions struct { Query string `url:"q,omitempty"` } +func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { + if input == nil { + return nil, fmt.Errorf("new_field must be an object") + } + + fieldIDValue, ok := input["id"] + if !ok { + fieldIDValue, ok = input["value"] + if !ok { + return nil, fmt.Errorf("new_field.id is required") + } + } + + fieldIDAsInt, ok := fieldIDValue.(float64) // JSON numbers are float64 + if !ok { + return nil, fmt.Errorf("new_field.id must be a number") + } + + value, ok := input["value"] + if !ok { + return nil, fmt.Errorf("new_field.value is required") + } + payload := &updateProjectItem{ID: int(fieldIDAsInt), Value: value} + + return payload, nil +} + // addOptions adds the parameters in opts as URL query parameters to s. opts // must be a struct whose fields may contain "url" tags. func addOptions(s string, opts any) (string, error) { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 147d2347d..dec0a9e37 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -202,6 +202,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG AddWriteTools( toolsets.NewServerTool(AddProjectItem(getClient, t)), toolsets.NewServerTool(DeleteProjectItem(getClient, t)), + toolsets.NewServerTool(UpdateProjectItem(getClient, t)), ) // Add toolsets to the group From b221d7082ef5a7dd86bd856c601aeaa2f01f99b1 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 8 Oct 2025 16:52:57 +0200 Subject: [PATCH 02/12] Update docs --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index fc8fae03e..94f785c9b 100644 --- a/README.md +++ b/README.md @@ -707,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 + - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) + - `new_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {"id": 123456, "value": "New Value"} (object, 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) +
From 6ed047572389a1dc4c142dda43c67a53cd3078c1 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Wed, 8 Oct 2025 16:58:56 +0200 Subject: [PATCH 03/12] Add tests and tool snaps --- .../__toolsnaps__/update_project_item.snap | 13 +- pkg/github/projects_test.go | 275 ++++++++++++++++++ 2 files changed, 282 insertions(+), 6 deletions(-) diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index ff2905282..20ea17f16 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -6,14 +6,15 @@ "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).", + "description": "The numeric ID of the issue or pull request to add to the project.", "type": "number" }, + "new_field": { + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "properties": {}, + "type": "object" + }, "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" @@ -36,7 +37,7 @@ "owner", "project_number", "item_id", - "fields" + "new_field" ], "type": "object" }, diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 19f23510b..85485dcc3 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1162,6 +1162,281 @@ func Test_AddProjectItem(t *testing.T) { } } +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, "new_field") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "new_field"}) + + orgUpdatedItem := map[string]any{ + "id": 801, + "content_type": "Issue", + } + userUpdatedItem := map[string]any{ + "id": 802, + "content_type": "PullRequest", + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + 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)) + require.Len(t, payload.Fields, 1) + assert.Equal(t, 101, payload.Fields[0].ID) + assert.Equal(t, "Done", payload.Fields[0].Value) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1001), + "item_id": float64(5555), + "new_field": map[string]any{ + "id": float64(101), + "value": "Done", + }, + }, + expectedID: 801, + }, + { + 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 struct { + Fields []struct { + ID int `json:"id"` + Value interface{} `json:"value"` + } `json:"fields"` + } + assert.NoError(t, json.Unmarshal(body, &payload)) + require.Len(t, payload.Fields, 1) + assert.Equal(t, 202, payload.Fields[0].ID) + assert.Equal(t, 42.0, payload.Fields[0].Value) // number value example + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2002), + "item_id": float64(6666), + "new_field": map[string]any{ + "id": float64(202), + "value": float64(42), + }, + }, + expectedID: 802, + }, + { + 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(3003), + "item_id": float64(7777), + "new_field": map[string]any{ + "id": float64(303), + "value": "In Progress", + }, + }, + expectError: true, + expectedErrMsg: "failed to add a project item", // implementation uses this message for update failures + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "new_field": 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(2), + "new_field": 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(2), + "new_field": 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), + "new_field": map[string]any{ + "id": float64(1), + "value": "X", + }, + }, + expectError: true, + }, + { + name: "missing new_field", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + }, + expectError: true, + }, + { + name: "new_field not object", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "new_field": "not-an-object", + }, + expectError: true, + }, + { + name: "new_field missing id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "new_field": map[string]any{}, + }, + expectError: true, + }, + { + name: "new_field missing value", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(2), + "new_field": map[string]any{ + "id": float64(9), + }, + }, + expectError: true, + }, + } + + 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") + case "missing new_field": + assert.Contains(t, text, "missing required parameter: new_field") + case "new_field not object": + assert.Contains(t, text, "new_field must be an object") + case "new_field missing id": + assert.Contains(t, text, "new_field.id is required") + case "new_field missing value": + assert.Contains(t, text, "new_field.value is required") + } + 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"]) + } + }) + } +} + func Test_DeleteProjectItem(t *testing.T) { mockClient := gh.NewClient(nil) tool, _ := DeleteProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) From 2bf55a699b018e57509f20738edc3bff278d4891 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 09:56:55 +0200 Subject: [PATCH 04/12] Remove unnecessary comments --- pkg/github/projects_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 85485dcc3..93bc0206c 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1245,7 +1245,7 @@ func Test_UpdateProjectItem(t *testing.T) { assert.NoError(t, json.Unmarshal(body, &payload)) require.Len(t, payload.Fields, 1) assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) // number value example + assert.Equal(t, 42.0, payload.Fields[0].Value) w.WriteHeader(http.StatusCreated) _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) }), @@ -1282,7 +1282,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, }, expectError: true, - expectedErrMsg: "failed to add a project item", // implementation uses this message for update failures + expectedErrMsg: "failed to add a project item", }, { name: "missing owner", From 5447a1d4acdccaa0728f46b615e6f9a39a09578b Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 10:52:55 +0200 Subject: [PATCH 05/12] Formatting and fixes --- README.md | 4 +- .../__toolsnaps__/update_project_item.snap | 4 +- pkg/github/projects.go | 265 +++++++++++++----- pkg/github/projects_test.go | 8 +- 4 files changed, 211 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 94f785c9b..142f87362 100644 --- a/README.md +++ b/README.md @@ -708,8 +708,8 @@ The following sets of tools are available (all are on by default): - `query`: Filter projects by a search query (matches title and description) (string, optional) - **update_project_item** - Update project item - - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - - `new_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {"id": 123456, "value": "New Value"} (object, required) + - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) + - `new_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set "value" to null. Example: {"id": 123456, "value": "New Value"} (object, 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/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index 20ea17f16..fbce1989d 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -7,11 +7,11 @@ "inputSchema": { "properties": { "item_id": { - "description": "The numeric ID of the issue or pull request to add to the project.", + "description": "The unique identifier of the project item. This is not the issue or pull request ID.", "type": "number" }, "new_field": { - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", "properties": {}, "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 5abfaf74b..f9e14daca 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -18,14 +18,31 @@ import ( "github.com/mark3labs/mcp-go/server" ) +const ( + ProjectUpdateFailedError = "failed to update a project item" + ProjectAddFailedError = "failed to add a project item" +) + 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 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", "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.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), + 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", "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.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 { @@ -105,10 +122,23 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( 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.")), + 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") @@ -176,11 +206,25 @@ func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (to 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), - mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), + 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.WithNumber("project_number", + 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 { @@ -251,11 +295,24 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_project_field", mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), - mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), + 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.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number.")), + mcp.WithNumber("field_id", + mcp.Required(), + mcp.Description("The field's id."), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { @@ -321,12 +378,28 @@ func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("list_project_items", mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), - mcp.WithString("query", mcp.Description("Search query to filter items")), - mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), + 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.WithNumber("project_number", mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithString("query", + mcp.Description("Search query to filter items"), + ), + 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 { @@ -408,11 +481,27 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_project_item", mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), - mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), 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.WithNumber("project_number", mcp.Required(), mcp.Description("The project's number.")), - mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The item's ID.")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), + 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.WithNumber("project_number", + mcp.Required(), + mcp.Description("The project's number."), + ), + mcp.WithNumber("item_id", + mcp.Required(), + mcp.Description("The item's ID."), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { @@ -478,12 +567,31 @@ 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.")), + 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 { @@ -522,11 +630,11 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) projectsURL = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) } - newProjectItem := &newProjectItem{ + newItem := &newProjectItem{ ID: int64(itemID), Type: toNewProjectType(itemType), } - httpRequest, err := client.NewRequest("POST", projectsURL, newProjectItem) + httpRequest, err := client.NewRequest("POST", projectsURL, newItem) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } @@ -535,19 +643,19 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) resp, err := client.Do(ctx, httpRequest, &addedItem) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to add a project item", + ProjectAddFailedError, resp, err, ), nil } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusCreated { 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 + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectAddFailedError, string(body))), nil } r, err := json.Marshal(convertToMinimalProjectItem(&addedItem)) if err != nil { @@ -561,12 +669,30 @@ func AddProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) 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 issue or pull request to add to the project.")), - mcp.WithObject("new_field", mcp.Required(), mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"New Value\"}")), + 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 unique identifier of the project item. This is not the issue or pull request ID."), + ), + mcp.WithObject("new_field", + mcp.Required(), + mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), + ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") if err != nil { @@ -622,19 +748,19 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu resp, err := client.Do(ctx, httpRequest, &addedItem) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to add a project item", + ProjectUpdateFailedError, resp, err, ), nil } defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + 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 add a project item: %s", string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil } r, err := json.Marshal(convertToMinimalProjectItem(&addedItem)) if err != nil { @@ -648,11 +774,27 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu 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).")), + 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 { @@ -774,24 +916,21 @@ func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { return nil, fmt.Errorf("new_field must be an object") } - fieldIDValue, ok := input["id"] + idField, ok := input["id"] if !ok { - fieldIDValue, ok = input["value"] - if !ok { - return nil, fmt.Errorf("new_field.id is required") - } + return nil, fmt.Errorf("new_field.id is required") } - fieldIDAsInt, ok := fieldIDValue.(float64) // JSON numbers are float64 + idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 if !ok { return nil, fmt.Errorf("new_field.id must be a number") } - value, ok := input["value"] + valueField, ok := input["value"] if !ok { return nil, fmt.Errorf("new_field.value is required") } - payload := &updateProjectItem{ID: int(fieldIDAsInt), Value: value} + payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} return payload, nil } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 93bc0206c..6477dfb53 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1022,7 +1022,7 @@ func Test_AddProjectItem(t *testing.T) { assert.NoError(t, json.Unmarshal(body, &payload)) assert.Equal(t, "PullRequest", payload.Type) assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) _, _ = w.Write(mock.MustMarshal(userItem)) }), ), @@ -1139,6 +1139,8 @@ func Test_AddProjectItem(t *testing.T) { assert.Contains(t, text, "missing required parameter: item_type") case "missing item_id": assert.Contains(t, text, "missing required parameter: item_id") + // case "api error": + // assert.Contains(t, text, ProjectAddFailedError) } return } @@ -1246,7 +1248,7 @@ func Test_UpdateProjectItem(t *testing.T) { require.Len(t, payload.Fields, 1) assert.Equal(t, 202, payload.Fields[0].ID) assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusOK) _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) }), ), @@ -1282,7 +1284,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, }, expectError: true, - expectedErrMsg: "failed to add a project item", + expectedErrMsg: "failed to update a project item", }, { name: "missing owner", From f3414a99bbaa0edec7b4e8133fde368e3d2c6f33 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 10:59:34 +0200 Subject: [PATCH 06/12] Extract error messages to const --- pkg/github/projects.go | 10 ++++++---- pkg/github/projects_test.go | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f9e14daca..1a1bed36a 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -21,6 +21,8 @@ import ( const ( ProjectUpdateFailedError = "failed to update a project item" ProjectAddFailedError = "failed to add a project item" + ProjectDeleteFailedError = "failed to delete a project item" + ProjectListFailedError = "failed to list project items" ) func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { @@ -451,7 +453,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun resp, err := client.Do(ctx, httpRequest, &projectItems) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project items", + ProjectListFailedError, resp, err, ), nil @@ -463,7 +465,7 @@ func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFun if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to list project items: %s", string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectListFailedError, string(body))), nil } minimalProjectItems := []MinimalProjectItem{} for _, item := range projectItems { @@ -832,7 +834,7 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu resp, err := client.Do(ctx, httpRequest, nil) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to delete a project item", + ProjectDeleteFailedError, resp, err, ), nil @@ -844,7 +846,7 @@ func DeleteProjectItem(getClient GetClientFn, t translations.TranslationHelperFu 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.NewToolResultError(fmt.Sprintf("%s: %s", ProjectDeleteFailedError, string(body))), nil } return mcp.NewToolResultText("project item successfully deleted"), nil } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 6477dfb53..827ce6242 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -697,7 +697,7 @@ func Test_ListProjectItems(t *testing.T) { "project_number": float64(789), }, expectError: true, - expectedErrMsg: "failed to list project items", + expectedErrMsg: ProjectListFailedError, }, { name: "missing owner", @@ -1054,7 +1054,7 @@ func Test_AddProjectItem(t *testing.T) { "item_id": float64(8888), }, expectError: true, - expectedErrMsg: "failed to add a project item", + expectedErrMsg: ProjectAddFailedError, }, { name: "missing owner", @@ -1511,7 +1511,7 @@ func Test_DeleteProjectItem(t *testing.T) { "item_id": float64(999), }, expectError: true, - expectedErrMsg: "failed to delete a project item", + expectedErrMsg: ProjectDeleteFailedError, }, { name: "missing owner", From eb4d7c4f8e43a89bc15b640c054add2f95844c31 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 11:04:06 +0200 Subject: [PATCH 07/12] Fix json tag --- pkg/github/projects.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 1a1bed36a..d9fba0fd0 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -870,7 +870,7 @@ 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"). + DataType string `json:"data_type,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. From d8356230431c7275a7e0ac041b8a26f87865a28c Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 11:04:55 +0200 Subject: [PATCH 08/12] Rename field --- pkg/github/projects.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d9fba0fd0..f64e19389 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -745,9 +745,9 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - addedItem := projectV2Item{} + updatedItem := projectV2Item{} - resp, err := client.Do(ctx, httpRequest, &addedItem) + resp, err := client.Do(ctx, httpRequest, &updatedItem) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, ProjectUpdateFailedError, @@ -764,7 +764,7 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu } return mcp.NewToolResultError(fmt.Sprintf("%s: %s", ProjectUpdateFailedError, string(body))), nil } - r, err := json.Marshal(convertToMinimalProjectItem(&addedItem)) + r, err := json.Marshal(convertToMinimalProjectItem(&updatedItem)) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } From 2032557c779d0fe365c0b71be82ac1da4177635b Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 13:38:10 +0200 Subject: [PATCH 09/12] Update params --- README.md | 3 +- .../__toolsnaps__/update_project_item.snap | 15 ++-- pkg/github/projects.go | 77 +++++++++++-------- pkg/github/projects_test.go | 9 ++- 4 files changed, 61 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index df09df7cc..e65089b25 100644 --- a/README.md +++ b/README.md @@ -762,8 +762,9 @@ The following sets of tools are available (all are on by default): - `query`: Filter projects by a search query (matches title and description) (string, optional) - **update_project_item** - Update project item + - `field_id`: The unique identifier of the project field to be updated. (number, required) + - `field_value`: The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {"id": 123456, "value": "Done"} (object, required) - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) - - `new_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set "value" to null. Example: {"id": 123456, "value": "New Value"} (object, 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/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index fbce1989d..2a28faed3 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -6,15 +6,19 @@ "description": "Update a specific Project item for a user or org", "inputSchema": { "properties": { - "item_id": { - "description": "The unique identifier of the project item. This is not the issue or pull request ID.", + "field_id": { + "description": "The unique identifier of the project field to be updated.", "type": "number" }, - "new_field": { - "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "field_value": { + "description": "The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"Done\"}", "properties": {}, "type": "object" }, + "item_id": { + "description": "The unique identifier of the project item. This is 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" @@ -37,7 +41,8 @@ "owner", "project_number", "item_id", - "new_field" + "field_id", + "field_value" ], "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index f64e19389..c0e0b12f1 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -691,9 +691,13 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu mcp.Required(), mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), ), - mcp.WithObject("new_field", + mcp.WithNumber("field_id", + mcp.Required(), + mcp.Description("The unique identifier of the project field to be updated."), + ), + mcp.WithObject("field_value", mcp.Required(), - mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), + mcp.Description("The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"Done\"}"), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -713,21 +717,28 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(err.Error()), nil } - rawNewField, exists := req.GetArguments()["new_field"] + fieldID, err := RequiredInt(req, "field_id") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + rawFieldValue, exists := req.GetArguments()["field_value"] if !exists { - return mcp.NewToolResultError("missing required parameter: new_field"), nil + return mcp.NewToolResultError("missing required parameter: field_value"), nil } - newField, ok := rawNewField.(map[string]any) - if !ok || newField == nil { - return mcp.NewToolResultError("new_field must be an object"), nil + fieldValue, ok := rawFieldValue.(map[string]any) + if !ok || fieldValue == nil { + return mcp.NewToolResultError("field_value must be an object"), nil } - updatePayload, err := buildUpdateProjectItem(newField) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil + valueField, ok := fieldValue["value"] + if !ok { + return nil, fmt.Errorf("field_value is required") } + updatePayload := &updateProjectItem{ID: fieldID, Value: valueField} + client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -913,29 +924,29 @@ type listProjectsOptions struct { Query string `url:"q,omitempty"` } -func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { - if input == nil { - return nil, fmt.Errorf("new_field must be an object") - } - - idField, ok := input["id"] - if !ok { - return nil, fmt.Errorf("new_field.id is required") - } - - idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 - if !ok { - return nil, fmt.Errorf("new_field.id must be a number") - } - - valueField, ok := input["value"] - if !ok { - return nil, fmt.Errorf("new_field.value is required") - } - payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} - - return payload, nil -} +// func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { +// if input == nil { +// return nil, fmt.Errorf("new_field must be an object") +// } + +// idField, ok := input["id"] +// if !ok { +// return nil, fmt.Errorf("new_field.id is required") +// } + +// idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 +// if !ok { +// return nil, fmt.Errorf("new_field.id must be a number") +// } + +// valueField, ok := input["value"] +// if !ok { +// return nil, fmt.Errorf("new_field.value is required") +// } +// payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} + +// return payload, nil +// } // addOptions adds the parameters in opts as URL query parameters to s. opts // must be a struct whose fields may contain "url" tags. diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 827ce6242..edd602edd 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1175,8 +1175,9 @@ func Test_UpdateProjectItem(t *testing.T) { 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, "new_field") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "new_field"}) + assert.Contains(t, tool.InputSchema.Properties, "field_id") + assert.Contains(t, tool.InputSchema.Properties, "field_value") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "field_id", "field_value"}) orgUpdatedItem := map[string]any{ "id": 801, @@ -1223,8 +1224,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1001), "item_id": float64(5555), - "new_field": map[string]any{ - "id": float64(101), + "field_id": float64(101), + "field_value": map[string]any{ "value": "Done", }, }, From 402eb906bfe6366bee6e106daa78d8babe133d32 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 13:54:21 +0200 Subject: [PATCH 10/12] Update test --- pkg/github/projects_test.go | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index edd602edd..e5272f298 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1259,8 +1259,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "user", "project_number": float64(2002), "item_id": float64(6666), - "new_field": map[string]any{ - "id": float64(202), + "field_id": float64(202), + "field_value": map[string]any{ "value": float64(42), }, }, @@ -1279,8 +1279,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(3003), "item_id": float64(7777), - "new_field": map[string]any{ - "id": float64(303), + "field_id": float64(303), + "field_value": map[string]any{ "value": "In Progress", }, }, @@ -1294,8 +1294,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1), "item_id": float64(2), + "field_id": float64(1), "new_field": map[string]any{ - "id": float64(1), "value": "X", }, }, @@ -1344,13 +1344,14 @@ func Test_UpdateProjectItem(t *testing.T) { expectError: true, }, { - name: "missing new_field", + name: "missing field_value", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", "project_number": float64(1), "item_id": float64(2), + "field_id": float64(2), }, expectError: true, }, @@ -1417,14 +1418,14 @@ func Test_UpdateProjectItem(t *testing.T) { assert.Contains(t, text, "missing required parameter: project_number") case "missing item_id": assert.Contains(t, text, "missing required parameter: item_id") - case "missing new_field": - assert.Contains(t, text, "missing required parameter: new_field") - case "new_field not object": - assert.Contains(t, text, "new_field must be an object") - case "new_field missing id": - assert.Contains(t, text, "new_field.id is required") - case "new_field missing value": - assert.Contains(t, text, "new_field.value is required") + case "missing field_value": + assert.Contains(t, text, "missing required parameter: field_value") + case "field_value not object": + assert.Contains(t, text, "field_value must be an object") + case "field_value missing id": + assert.Contains(t, text, "missing required parameter: field_id") + case "field_value missing value": + assert.Contains(t, text, "field_value.value is required") } return } From d1ba41fad973493304ed27fd6238a10c3c7e055b Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 16:30:06 +0200 Subject: [PATCH 11/12] Update tool example --- README.md | 2 +- pkg/github/__toolsnaps__/update_project_item.snap | 2 +- pkg/github/projects.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a31067aae..ebecaf321 100644 --- a/README.md +++ b/README.md @@ -808,7 +808,7 @@ The following sets of tools are available (all are on by default): - **update_project_item** - Update project item - `field_id`: The unique identifier of the project field to be updated. (number, required) - - `field_value`: The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {"id": 123456, "value": "Done"} (object, required) + - `field_value`: The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {"field_value": "Done"} (object, required) - `item_id`: The unique identifier of the project item. This is 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) diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index 2a28faed3..94cb816bb 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -11,7 +11,7 @@ "type": "number" }, "field_value": { - "description": "The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"Done\"}", + "description": "The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"field_value\": \"Done\"}", "properties": {}, "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index c0e0b12f1..9877a76d4 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -697,7 +697,7 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu ), mcp.WithObject("field_value", mcp.Required(), - mcp.Description("The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"id\": 123456, \"value\": \"Done\"}"), + mcp.Description("The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"field_value\": \"Done\"}"), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") From fc68db59c2dde03641cbe209ed28b07925085f68 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 9 Oct 2025 17:14:10 +0200 Subject: [PATCH 12/12] . --- README.md | 3 +- .../__toolsnaps__/update_project_item.snap | 17 ++--- pkg/github/projects.go | 73 ++++++++----------- pkg/github/projects_test.go | 25 +++---- 4 files changed, 50 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index ebecaf321..396fb1661 100644 --- a/README.md +++ b/README.md @@ -807,12 +807,11 @@ The following sets of tools are available (all are on by default): - `query`: Filter projects by a search query (matches title and description) (string, optional) - **update_project_item** - Update project item - - `field_id`: The unique identifier of the project field to be updated. (number, required) - - `field_value`: The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {"field_value": "Done"} (object, required) - `item_id`: The unique identifier of the project item. This is 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) + - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set "value" to null. Example: {"id": 123456, "value": "New Value"} (object, required)
diff --git a/pkg/github/__toolsnaps__/update_project_item.snap b/pkg/github/__toolsnaps__/update_project_item.snap index 94cb816bb..96a8e749a 100644 --- a/pkg/github/__toolsnaps__/update_project_item.snap +++ b/pkg/github/__toolsnaps__/update_project_item.snap @@ -6,15 +6,6 @@ "description": "Update a specific Project item for a user or org", "inputSchema": { "properties": { - "field_id": { - "description": "The unique identifier of the project field to be updated.", - "type": "number" - }, - "field_value": { - "description": "The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"field_value\": \"Done\"}", - "properties": {}, - "type": "object" - }, "item_id": { "description": "The unique identifier of the project item. This is not the issue or pull request ID.", "type": "number" @@ -34,6 +25,11 @@ "project_number": { "description": "The project's number.", "type": "number" + }, + "updated_field": { + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + "properties": {}, + "type": "object" } }, "required": [ @@ -41,8 +37,7 @@ "owner", "project_number", "item_id", - "field_id", - "field_value" + "updated_field" ], "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 9877a76d4..f7bc94677 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -691,13 +691,9 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu mcp.Required(), mcp.Description("The unique identifier of the project item. This is not the issue or pull request ID."), ), - mcp.WithNumber("field_id", - mcp.Required(), - mcp.Description("The unique identifier of the project field to be updated."), - ), - mcp.WithObject("field_value", + mcp.WithObject("updated_field", mcp.Required(), - mcp.Description("The new value for the field: For text, number, and date fields, provide the new value directly. For single select and iteration fields, provide the ID of the option or iteration. To clear the field, set this to null. Example: {\"field_value\": \"Done\"}"), + mcp.Description("Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set \"value\" to null. Example: {\"id\": 123456, \"value\": \"New Value\"}"), ), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := RequiredParam[string](req, "owner") @@ -717,28 +713,21 @@ func UpdateProjectItem(getClient GetClientFn, t translations.TranslationHelperFu return mcp.NewToolResultError(err.Error()), nil } - fieldID, err := RequiredInt(req, "field_id") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - rawFieldValue, exists := req.GetArguments()["field_value"] + rawUpdatedField, exists := req.GetArguments()["updated_field"] if !exists { - return mcp.NewToolResultError("missing required parameter: field_value"), nil + return mcp.NewToolResultError("missing required parameter: updated_field"), nil } - fieldValue, ok := rawFieldValue.(map[string]any) + fieldValue, ok := rawUpdatedField.(map[string]any) if !ok || fieldValue == nil { return mcp.NewToolResultError("field_value must be an object"), nil } - valueField, ok := fieldValue["value"] - if !ok { - return nil, fmt.Errorf("field_value is required") + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - updatePayload := &updateProjectItem{ID: fieldID, Value: valueField} - client, err := getClient(ctx) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -924,29 +913,29 @@ type listProjectsOptions struct { Query string `url:"q,omitempty"` } -// func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { -// if input == nil { -// return nil, fmt.Errorf("new_field must be an object") -// } - -// idField, ok := input["id"] -// if !ok { -// return nil, fmt.Errorf("new_field.id is required") -// } - -// idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 -// if !ok { -// return nil, fmt.Errorf("new_field.id must be a number") -// } - -// valueField, ok := input["value"] -// if !ok { -// return nil, fmt.Errorf("new_field.value is required") -// } -// payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} - -// return payload, nil -// } +func buildUpdateProjectItem(input map[string]any) (*updateProjectItem, error) { + if input == nil { + return nil, fmt.Errorf("updated_field must be an object") + } + + idField, ok := input["id"] + if !ok { + return nil, fmt.Errorf("updated_field.id is required") + } + + idFieldAsFloat64, ok := idField.(float64) // JSON numbers are float64 + if !ok { + return nil, fmt.Errorf("updated_field.id must be a number") + } + + valueField, ok := input["value"] + if !ok { + return nil, fmt.Errorf("updated_field.value is required") + } + payload := &updateProjectItem{ID: int(idFieldAsFloat64), Value: valueField} + + return payload, nil +} // addOptions adds the parameters in opts as URL query parameters to s. opts // must be a struct whose fields may contain "url" tags. diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e5272f298..52adb73e6 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1175,9 +1175,8 @@ func Test_UpdateProjectItem(t *testing.T) { 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, "field_id") - assert.Contains(t, tool.InputSchema.Properties, "field_value") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "field_id", "field_value"}) + assert.Contains(t, tool.InputSchema.Properties, "updated_field") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}) orgUpdatedItem := map[string]any{ "id": 801, @@ -1224,8 +1223,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1001), "item_id": float64(5555), - "field_id": float64(101), - "field_value": map[string]any{ + "updated_field": map[string]any{ + "id": float64(101), "value": "Done", }, }, @@ -1259,8 +1258,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "user", "project_number": float64(2002), "item_id": float64(6666), - "field_id": float64(202), - "field_value": map[string]any{ + "updated_field": map[string]any{ + "id": float64(202), "value": float64(42), }, }, @@ -1279,8 +1278,8 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(3003), "item_id": float64(7777), - "field_id": float64(303), - "field_value": map[string]any{ + "updated_field": map[string]any{ + "id": float64(303), "value": "In Progress", }, }, @@ -1363,7 +1362,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1), "item_id": float64(2), - "new_field": "not-an-object", + "updated_field": "not-an-object", }, expectError: true, }, @@ -1375,7 +1374,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1), "item_id": float64(2), - "new_field": map[string]any{}, + "updated_field": map[string]any{}, }, expectError: true, }, @@ -1387,7 +1386,7 @@ func Test_UpdateProjectItem(t *testing.T) { "owner_type": "org", "project_number": float64(1), "item_id": float64(2), - "new_field": map[string]any{ + "updated_field": map[string]any{ "id": float64(9), }, }, @@ -1419,7 +1418,7 @@ func Test_UpdateProjectItem(t *testing.T) { case "missing item_id": assert.Contains(t, text, "missing required parameter: item_id") case "missing field_value": - assert.Contains(t, text, "missing required parameter: field_value") + assert.Contains(t, text, "missing required parameter: updated_field") case "field_value not object": assert.Contains(t, text, "field_value must be an object") case "field_value missing id":