Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -761,6 +761,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)

</details>

<details>
Expand Down
13 changes: 7 additions & 6 deletions pkg/github/__toolsnaps__/update_project_item.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -36,7 +37,7 @@
"owner",
"project_number",
"item_id",
"fields"
"new_field"
],
"type": "object"
},
Expand Down
4 changes: 4 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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,
Expand Down
127 changes: 126 additions & 1 deletion pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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.
Expand All @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down
Loading