From bdd21a504bd58b76161527d5811418075c244545 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 31 Dec 2025 10:26:30 +0000 Subject: [PATCH 1/8] initial projects consolidation --- README.md | 29 + pkg/github/__toolsnaps__/projects_get.snap | 59 ++ pkg/github/__toolsnaps__/projects_list.snap | 66 ++ pkg/github/__toolsnaps__/projects_write.snap | 60 ++ pkg/github/projects.go | 775 ++++++++++++++++++- pkg/github/projects_test.go | 694 +++++++++++++++++ pkg/github/tools.go | 5 + 7 files changed, 1679 insertions(+), 9 deletions(-) create mode 100644 pkg/github/__toolsnaps__/projects_get.snap create mode 100644 pkg/github/__toolsnaps__/projects_list.snap create mode 100644 pkg/github/__toolsnaps__/projects_write.snap diff --git a/README.md b/README.md index ce6eb81cb..51a074392 100644 --- a/README.md +++ b/README.md @@ -973,6 +973,35 @@ The following sets of tools are available: - `per_page`: Results per page (max 50) (number, optional) - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) +- **projects_get** - Get details of GitHub Projects resources + - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) + - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) + - `method`: The method to execute (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) + +- **projects_list** - List GitHub Projects resources + - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) + - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) + - `method`: The action to perform (string, required) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `per_page`: Results per page (max 50) (number, optional) + - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) + +- **projects_write** - Modify GitHub Project items + - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add. (number, optional) + - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) + - `method`: The method to execute (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) + - `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"}. Required for 'update_project_item' method. (object, optional) + - **update_project_item** - Update project item - `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) diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap new file mode 100644 index 000000000..9758de0f2 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Projects resources" + }, + "description": "Get details about specific GitHub Projects resources.\nUse this tool to get details about individual projects, project fields, and project items by their unique IDs.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "field_id": { + "type": "number", + "description": "The field's ID. Required for 'get_project_field' method." + }, + "fields": { + "type": "array", + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + "items": { + "type": "string" + } + }, + "item_id": { + "type": "number", + "description": "The item's ID. Required for 'get_project_item' method." + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "get_project", + "get_project_field", + "get_project_item" + ] + }, + "owner": { + "type": "string", + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's number." + } + } + }, + "name": "projects_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap new file mode 100644 index 000000000..7cc2e2df7 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -0,0 +1,66 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Projects resources" + }, + "description": "Tools for listing GitHub Projects resources.\nUse this tool to list projects for a user or organization, or list project fields and items for a specific project.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner" + ], + "properties": { + "after": { + "type": "string", + "description": "Forward pagination cursor from previous pageInfo.nextCursor." + }, + "before": { + "type": "string", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + }, + "fields": { + "type": "array", + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + "items": { + "type": "string" + } + }, + "method": { + "type": "string", + "description": "The action to perform", + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items" + ] + }, + "owner": { + "type": "string", + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "per_page": { + "type": "number", + "description": "Results per page (max 50)" + }, + "project_number": { + "type": "number", + "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods." + }, + "query": { + "type": "string", + "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax." + } + } + }, + "name": "projects_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap new file mode 100644 index 000000000..2224590c5 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Modify GitHub Project items" + }, + "description": "Add, update, or delete project items in a GitHub Project.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "item_id": { + "type": "number", + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add." + }, + "item_type": { + "type": "string", + "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + "enum": [ + "issue", + "pull_request" + ] + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "add_project_item", + "update_project_item", + "delete_project_item" + ] + }, + "owner": { + "type": "string", + "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." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's number." + }, + "updated_field": { + "type": "object", + "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\"}. Required for 'update_project_item' method." + } + } + }, + "name": "projects_write" +} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 18c1f778b..3a4cb0bc2 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -25,8 +25,25 @@ const ( MaxProjectsPerPage = 50 ) +// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools +// in favor of the consolidated project tools. +const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" + +// Method constants for consolidated project tools +const ( + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" +) + func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -140,10 +157,12 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -228,10 +247,12 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -334,10 +355,12 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -426,10 +449,12 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -562,10 +587,12 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -668,10 +695,12 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -779,10 +808,12 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -891,10 +922,12 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", @@ -976,6 +1009,730 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText("project item successfully deleted"), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsList returns the tool and handler for listing GitHub Projects resources. +func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_list", + Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", + `Tools for listing GitHub Projects resources. +Use this tool to list projects for a user or organization, or list project fields and items for a specific project. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + projectsMethodListProjects, + projectsMethodListProjectFields, + projectsMethodListProjectItems, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + }, + "query": { + Type: "string", + Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, + }, + "fields": { + Type: "array", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"method", "owner_type", "owner"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodListProjects: + return listProjects(ctx, client, args, owner, ownerType) + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsGet returns the tool and handler for getting GitHub Projects resources. +func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_get", + Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. +Use this tool to get details about individual projects, project fields, and project items by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodGetProject, + projectsMethodGetProjectField, + projectsMethodGetProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's ID. Required for 'get_project_field' method.", + }, + "item_id": { + Type: "number", + Description: "The item's ID. Required for 'get_project_item' method.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"method", "owner_type", "owner", "project_number"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodGetProject: + return getProject(ctx, client, owner, ownerType, projectNumber) + case projectsMethodGetProjectField: + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + case projectsMethodGetProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. +func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_write", + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodAddProjectItem, + projectsMethodUpdateProjectItem, + projectsMethodDeleteProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.", + }, + "item_type": { + Type: "string", + Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + Enum: []any{"issue", "pull_request"}, + }, + "updated_field": { + Type: "object", + 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\"}. Required for 'update_project_item' method.", + }, + }, + Required: []string{"method", "owner_type", "owner", "project_number"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodAddProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType) + case projectsMethodUpdateProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rawUpdatedField, exists := args["updated_field"] + if !exists { + return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil + } + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return utils.NewToolResultError("updated_field must be an object"), nil, nil + } + return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + case projectsMethodDeleteProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// Helper functions for consolidated projects tools + +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + } + + if ownerType == "org" { + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listProjectItems(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectItems []*github.ProjectV2Item + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + opts := &github.ListProjectItemsOptions{ + Fields: fields, + ListProjectsOptions: github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var project *github.ProjectV2 + var err error + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectField *github.ProjectV2Field + var err error + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectItem *github.ProjectV2Item + var opts *github.GetProjectItemOptions + var err error + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(projectItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func addProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, itemType string) (*mcp.CallToolResult, any, error) { + if itemType != "issue" && itemType != "pull_request" { + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + newItem := &github.AddProjectItemOptions{ + ID: itemID, + Type: toNewProjectType(itemType), + } + + var resp *github.Response + var addedItem *github.ProjectV2Item + var err error + + if ownerType == "org" { + addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) + } else { + addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectAddFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil + } + r, err := json.Marshal(addedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var err error + + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil } type pageInfo struct { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e443b9ecd..b90d7eecd 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1709,3 +1709,697 @@ func Test_DeleteProjectItem(t *testing.T) { }) } } + +// Tests for consolidated project tools + +func Test_ProjectsList(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsList(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_list", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "query") + assert.Contains(t, inputSchema.Properties, "fields") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner"}) +} + +func Test_ProjectsList_ListProjects(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedLength int + }{ + { + name: "success organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgProjects)) + }), + ), + ), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userProjects)) + }), + ), + ), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "missing required parameter method", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "unknown method", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectError { + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + projects, ok := response["projects"].([]interface{}) + require.True(t, ok) + assert.Equal(t, tc.expectedLength, len(projects)) + }) + } +} + +func Test_ProjectsList_ListProjectFields(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(fields)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + fieldsList, ok := response["fields"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(fieldsList)) + }) + + t.Run("missing project_number", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: project_number") + }) +} + +func Test_ProjectsList_ListProjectItems(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(items)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + itemsList, ok := response["items"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(itemsList)) + }) +} + +func Test_ProjectsGet(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsGet(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_get", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "field_id") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) +} + +func Test_ProjectsGet_GetProject(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + project := map[string]any{"id": 123, "title": "Project Title"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(project)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsGet_GetProjectField(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields/101", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(field)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_id": float64(101), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing field_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: field_id") + }) +} + +func Test_ProjectsGet_GetProjectItem(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(item)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} + +func Test_ProjectsWrite(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.Contains(t, inputSchema.Properties, "item_type") + assert.Contains(t, inputSchema.Properties, "updated_field") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) + + // Verify DestructiveHint is set + assert.NotNil(t, toolDef.Tool.Annotations) + assert.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) +} + +func Test_ProjectsWrite_AddProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + addedItem := map[string]any{"id": 2001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if payload["id"] == nil || payload["type"] == nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"bad request"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(addedItem)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "issue", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing item_type", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_type") + }) + + t.Run("invalid item_type", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "invalid_type", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "item_type must be either 'issue' or 'pull_request'") + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := map[string]any{"id": 1001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(updatedItem)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing updated_field", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: updated_field") + }) +} + +func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "project item successfully deleted") + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 62b67af6f..0ef692e61 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -284,6 +284,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { DeleteProjectItem(t), UpdateProjectItem(t), + // Consolidated project tools (enabled via feature flag) + ProjectsList(t), + ProjectsGet(t), + ProjectsWrite(t), + // Label tools GetLabel(t), GetLabelForLabelsToolset(t), From 7604acdc1d68f1b4faf5bd1f35306a0db3afc34e Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 31 Dec 2025 11:59:16 +0000 Subject: [PATCH 2/8] update tool aliases --- pkg/github/deprecated_tool_aliases.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 63394770e..4415731fb 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -28,4 +28,15 @@ var DeprecatedToolAliases = map[string]string{ "rerun_failed_jobs": "actions_run_trigger", "cancel_workflow_run": "actions_run_trigger", "delete_workflow_run_logs": "actions_run_trigger", + + // Projects tools consolidated + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", } From 4a53df5c3ad434f867886d32cfb1b62826eaf49f Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 15:27:57 +0000 Subject: [PATCH 3/8] hold-bac feature flag --- pkg/github/projects.go | 17 ++++++++++++ pkg/inventory/filters.go | 13 ++++++--- pkg/inventory/prompts.go | 3 ++ pkg/inventory/registry_test.go | 50 ++++++++++++++++++++++++++++++++++ pkg/inventory/resources.go | 3 ++ pkg/inventory/server_tool.go | 6 ++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 64ad47f6d..1df3fdae0 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -29,6 +29,14 @@ const ( // in favor of the consolidated project tools. const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" +// FeatureFlagHoldBackLegacyProjects allows users to keep the old individual project tools +// even after FeatureFlagConsolidatedProjects is enabled. This provides a transition period +// for users who need more time to migrate to the consolidated tools. +// +// Deprecated: This flag will be removed in a future release. Users should migrate to +// the consolidated project tools (projects_list, projects_get, projects_write). +const FeatureFlagHoldBackLegacyProjects = "remote_mcp_holdback_legacy_projects" + // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" @@ -158,6 +166,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -248,6 +257,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -356,6 +366,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -450,6 +461,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -588,6 +600,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -696,6 +709,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -809,6 +823,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -923,6 +938,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -1011,6 +1027,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index c5156e61a..a852760ce 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -38,13 +38,18 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool // isFeatureFlagAllowed checks if an item passes feature flag filtering. // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled // - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { +// - If FeatureFlagHoldBack is set and enabled, it overrides FeatureFlagDisable (keeps tool available) +func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag, holdBackFlag string) bool { // Check enable flag - item requires this flag to be on if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { return false } // Check disable flag - item is excluded if this flag is on if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { + // Check if hold-back flag overrides the disable + if holdBackFlag != "" && r.checkFeatureFlag(ctx, holdBackFlag) { + return true // Hold-back keeps tool enabled during transition + } return false } return true @@ -70,7 +75,7 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { } } // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable, tool.FeatureFlagHoldBack) { return false } // 3. Check read-only filter (applies to all tools) @@ -130,7 +135,7 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso for i := range r.resourceTemplates { res := &r.resourceTemplates[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable, res.FeatureFlagHoldBack) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -157,7 +162,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { for i := range r.prompts { prompt := &r.prompts[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable, prompt.FeatureFlagHoldBack) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { diff --git a/pkg/inventory/prompts.go b/pkg/inventory/prompts.go index 648f20f9c..2ef57deb8 100644 --- a/pkg/inventory/prompts.go +++ b/pkg/inventory/prompts.go @@ -14,6 +14,9 @@ type ServerPrompt struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt // to be omitted. Used to disable prompts when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the prompt available during a transition period. + FeatureFlagHoldBack string } // NewServerPrompt creates a new ServerPrompt with toolset metadata. diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 742ad3646..305f0fe86 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1077,6 +1077,56 @@ func TestFeatureFlagBoth(t *testing.T) { } } +func TestFeatureFlagHoldBack(t *testing.T) { + // Tool with disable flag and hold-back flag (simulates legacy tool during consolidation) + legacyTool := mockToolWithFlags("legacy_tool", "toolset1", true, "", "consolidation_flag") + legacyTool.FeatureFlagHoldBack = "holdback_flag" + + tools := []ServerTool{ + mockTool("always_available", "toolset1", true), + legacyTool, + } + + // Consolidation OFF, hold-back OFF -> legacy tool available (normal operation) + checkerAllOff := func(_ context.Context, _ string) (bool, error) { return false, nil } + regAllOff := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerAllOff).Build() + availableAllOff := regAllOff.AvailableTools(context.Background()) + if len(availableAllOff) != 2 { + t.Fatalf("Expected 2 tools when both flags off, got %d", len(availableAllOff)) + } + + // Consolidation ON, hold-back OFF -> legacy tool excluded (migrated to new tools) + checkerConsolidationOnly := func(_ context.Context, flag string) (bool, error) { + return flag == "consolidation_flag", nil + } + regConsolidationOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerConsolidationOnly).Build() + availableConsolidationOnly := regConsolidationOnly.AvailableTools(context.Background()) + if len(availableConsolidationOnly) != 1 { + t.Fatalf("Expected 1 tool when consolidation on but holdback off, got %d", len(availableConsolidationOnly)) + } + if availableConsolidationOnly[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", availableConsolidationOnly[0].Tool.Name) + } + + // Consolidation ON, hold-back ON -> legacy tool available (user opted to hold back) + checkerBothOn := func(_ context.Context, _ string) (bool, error) { return true, nil } + regBothOn := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerBothOn).Build() + availableBothOn := regBothOn.AvailableTools(context.Background()) + if len(availableBothOn) != 2 { + t.Fatalf("Expected 2 tools when both consolidation and holdback on, got %d", len(availableBothOn)) + } + + // Consolidation OFF, hold-back ON -> legacy tool available (hold-back has no effect when consolidation off) + checkerHoldbackOnly := func(_ context.Context, flag string) (bool, error) { + return flag == "holdback_flag", nil + } + regHoldbackOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerHoldbackOnly).Build() + availableHoldbackOnly := regHoldbackOnly.AvailableTools(context.Background()) + if len(availableHoldbackOnly) != 2 { + t.Fatalf("Expected 2 tools when only holdback on, got %d", len(availableHoldbackOnly)) + } +} + func TestFeatureFlagError(t *testing.T) { tools := []ServerTool{ mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 6de037d58..83904355c 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -22,6 +22,9 @@ type ServerResourceTemplate struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource // to be omitted. Used to disable resources when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the resource available during a transition period. + FeatureFlagHoldBack string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 362ee2643..5f26c9285 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -64,6 +64,12 @@ type ServerTool struct { // to be omitted. Used to disable tools when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the tool available. This allows users to "hold back" + // on a deprecation by opting to keep the old tools during a transition period. + // Used during tool consolidation to give users time to migrate. + FeatureFlagHoldBack string + // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled // (subject to FeatureFlagEnable/FeatureFlagDisable checks). From 4927b36b00b55a40179733bbc05b4ba1ea4f88a7 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 15:28:42 +0000 Subject: [PATCH 4/8] update docs --- README.md | 29 ----------------------------- docs/tool-renaming.md | 9 +++++++++ 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 159b75641..af92cfd0b 100644 --- a/README.md +++ b/README.md @@ -932,35 +932,6 @@ The following sets of tools are available: - `per_page`: Results per page (max 50) (number, optional) - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) -- **projects_get** - Get details of GitHub Projects resources - - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) - - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - - `method`: The method to execute (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) - -- **projects_list** - List GitHub Projects resources - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) - - `method`: The action to perform (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) - - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) - -- **projects_write** - Modify GitHub Project items - - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add. (number, optional) - - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) - - `method`: The method to execute (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) - - `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"}. Required for 'update_project_item' method. (object, optional) - - **update_project_item** - Update project item - `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) diff --git a/docs/tool-renaming.md b/docs/tool-renaming.md index cf342f6dc..050ac9b77 100644 --- a/docs/tool-renaming.md +++ b/docs/tool-renaming.md @@ -46,15 +46,23 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | Old Name | New Name | |----------|----------| +| `add_project_item` | `projects_write` | | `cancel_workflow_run` | `actions_run_trigger` | +| `delete_project_item` | `projects_write` | | `delete_workflow_run_logs` | `actions_run_trigger` | | `download_workflow_run_artifact` | `actions_get` | +| `get_project` | `projects_get` | +| `get_project_field` | `projects_get` | +| `get_project_item` | `projects_get` | | `get_workflow` | `actions_get` | | `get_workflow_job` | `actions_get` | | `get_workflow_job_logs` | `actions_get` | | `get_workflow_run` | `actions_get` | | `get_workflow_run_logs` | `actions_get` | | `get_workflow_run_usage` | `actions_get` | +| `list_project_fields` | `projects_list` | +| `list_project_items` | `projects_list` | +| `list_projects` | `projects_list` | | `list_workflow_jobs` | `actions_list` | | `list_workflow_run_artifacts` | `actions_list` | | `list_workflow_runs` | `actions_list` | @@ -62,4 +70,5 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | `rerun_failed_jobs` | `actions_run_trigger` | | `rerun_workflow_run` | `actions_run_trigger` | | `run_workflow` | `actions_run_trigger` | +| `update_project_item` | `projects_write` | From 05d865ecbb220dec724aeb792c08ed729110bb97 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 16:18:40 +0000 Subject: [PATCH 5/8] revert "hold-bac feature flag" --- pkg/github/projects.go | 17 ------------ pkg/inventory/filters.go | 13 +++------ pkg/inventory/prompts.go | 3 -- pkg/inventory/registry_test.go | 50 ---------------------------------- pkg/inventory/resources.go | 3 -- pkg/inventory/server_tool.go | 6 ---- 6 files changed, 4 insertions(+), 88 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index bb53fac45..9a5d25839 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -30,14 +30,6 @@ const ( // in favor of the consolidated project tools. const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" -// FeatureFlagHoldBackLegacyProjects allows users to keep the old individual project tools -// even after FeatureFlagConsolidatedProjects is enabled. This provides a transition period -// for users who need more time to migrate to the consolidated tools. -// -// Deprecated: This flag will be removed in a future release. Users should migrate to -// the consolidated project tools (projects_list, projects_get, projects_write). -const FeatureFlagHoldBackLegacyProjects = "remote_mcp_holdback_legacy_projects" - // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" @@ -168,7 +160,6 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -260,7 +251,6 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -370,7 +360,6 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -466,7 +455,6 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -606,7 +594,6 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -716,7 +703,6 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -831,7 +817,6 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -947,7 +932,6 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -1037,7 +1021,6 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index a852760ce..c5156e61a 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -38,18 +38,13 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool // isFeatureFlagAllowed checks if an item passes feature flag filtering. // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled // - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -// - If FeatureFlagHoldBack is set and enabled, it overrides FeatureFlagDisable (keeps tool available) -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag, holdBackFlag string) bool { +func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { // Check enable flag - item requires this flag to be on if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { return false } // Check disable flag - item is excluded if this flag is on if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { - // Check if hold-back flag overrides the disable - if holdBackFlag != "" && r.checkFeatureFlag(ctx, holdBackFlag) { - return true // Hold-back keeps tool enabled during transition - } return false } return true @@ -75,7 +70,7 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { } } // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable, tool.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { return false } // 3. Check read-only filter (applies to all tools) @@ -135,7 +130,7 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso for i := range r.resourceTemplates { res := &r.resourceTemplates[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable, res.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -162,7 +157,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { for i := range r.prompts { prompt := &r.prompts[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable, prompt.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { diff --git a/pkg/inventory/prompts.go b/pkg/inventory/prompts.go index 2ef57deb8..648f20f9c 100644 --- a/pkg/inventory/prompts.go +++ b/pkg/inventory/prompts.go @@ -14,9 +14,6 @@ type ServerPrompt struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt // to be omitted. Used to disable prompts when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the prompt available during a transition period. - FeatureFlagHoldBack string } // NewServerPrompt creates a new ServerPrompt with toolset metadata. diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 305f0fe86..742ad3646 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1077,56 +1077,6 @@ func TestFeatureFlagBoth(t *testing.T) { } } -func TestFeatureFlagHoldBack(t *testing.T) { - // Tool with disable flag and hold-back flag (simulates legacy tool during consolidation) - legacyTool := mockToolWithFlags("legacy_tool", "toolset1", true, "", "consolidation_flag") - legacyTool.FeatureFlagHoldBack = "holdback_flag" - - tools := []ServerTool{ - mockTool("always_available", "toolset1", true), - legacyTool, - } - - // Consolidation OFF, hold-back OFF -> legacy tool available (normal operation) - checkerAllOff := func(_ context.Context, _ string) (bool, error) { return false, nil } - regAllOff := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerAllOff).Build() - availableAllOff := regAllOff.AvailableTools(context.Background()) - if len(availableAllOff) != 2 { - t.Fatalf("Expected 2 tools when both flags off, got %d", len(availableAllOff)) - } - - // Consolidation ON, hold-back OFF -> legacy tool excluded (migrated to new tools) - checkerConsolidationOnly := func(_ context.Context, flag string) (bool, error) { - return flag == "consolidation_flag", nil - } - regConsolidationOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerConsolidationOnly).Build() - availableConsolidationOnly := regConsolidationOnly.AvailableTools(context.Background()) - if len(availableConsolidationOnly) != 1 { - t.Fatalf("Expected 1 tool when consolidation on but holdback off, got %d", len(availableConsolidationOnly)) - } - if availableConsolidationOnly[0].Tool.Name != "always_available" { - t.Errorf("Expected always_available, got %s", availableConsolidationOnly[0].Tool.Name) - } - - // Consolidation ON, hold-back ON -> legacy tool available (user opted to hold back) - checkerBothOn := func(_ context.Context, _ string) (bool, error) { return true, nil } - regBothOn := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerBothOn).Build() - availableBothOn := regBothOn.AvailableTools(context.Background()) - if len(availableBothOn) != 2 { - t.Fatalf("Expected 2 tools when both consolidation and holdback on, got %d", len(availableBothOn)) - } - - // Consolidation OFF, hold-back ON -> legacy tool available (hold-back has no effect when consolidation off) - checkerHoldbackOnly := func(_ context.Context, flag string) (bool, error) { - return flag == "holdback_flag", nil - } - regHoldbackOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerHoldbackOnly).Build() - availableHoldbackOnly := regHoldbackOnly.AvailableTools(context.Background()) - if len(availableHoldbackOnly) != 2 { - t.Fatalf("Expected 2 tools when only holdback on, got %d", len(availableHoldbackOnly)) - } -} - func TestFeatureFlagError(t *testing.T) { tools := []ServerTool{ mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 83904355c..6de037d58 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -22,9 +22,6 @@ type ServerResourceTemplate struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource // to be omitted. Used to disable resources when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the resource available during a transition period. - FeatureFlagHoldBack string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 728b44697..095bedf2b 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -64,12 +64,6 @@ type ServerTool struct { // to be omitted. Used to disable tools when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the tool available. This allows users to "hold back" - // on a deprecation by opting to keep the old tools during a transition period. - // Used during tool consolidation to give users time to migrate. - FeatureFlagHoldBack string - // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled // (subject to FeatureFlagEnable/FeatureFlagDisable checks). From a8b618863603b7a1762111e13bc4dd5e272c191b Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 16:23:00 +0000 Subject: [PATCH 6/8] fix project tools to add scope to newtool init --- pkg/github/projects.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 9a5d25839..6e43a3e9b 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1090,6 +1090,7 @@ Use this tool to list projects for a user or organization, or list project field Required: []string{"method", "owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -1184,6 +1185,7 @@ Use this tool to get details about individual projects, project fields, and proj Required: []string{"method", "owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { @@ -1292,6 +1294,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { From 2c8823d534aed2c22896ebf0efcfe078838e7ce8 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 6 Jan 2026 13:24:24 +0000 Subject: [PATCH 7/8] add http resp code checking for getProjectItem --- pkg/github/projects.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 6e43a3e9b..8af181a72 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1629,6 +1629,14 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType } defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project item", resp, body), nil, nil + } + r, err := json.Marshal(projectItem) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) From 36802d52539313d78ef8ef8e4a13a127431bc8be Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 6 Jan 2026 13:27:43 +0000 Subject: [PATCH 8/8] update tests to use new mock pattern --- pkg/github/projects_test.go | 154 +++++++++++------------------------- 1 file changed, 46 insertions(+), 108 deletions(-) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 96bd4d128..9819e7d7e 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1565,15 +1565,9 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }{ { name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octo-org", @@ -1584,15 +1578,9 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octocat", @@ -1603,7 +1591,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "missing required parameter method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1613,7 +1601,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "unknown method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "unknown_method", "owner": "octo-org", @@ -1662,15 +1650,9 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(fields)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1698,7 +1680,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { }) t.Run("missing project_number", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1724,15 +1706,9 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(items)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1783,15 +1759,9 @@ func Test_ProjectsGet_GetProject(t *testing.T) { project := map[string]any{"id": 123, "title": "Project Title"} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(project)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1817,7 +1787,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { }) t.Run("unknown method", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1844,15 +1814,9 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields/101", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(field)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1879,7 +1843,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { }) t.Run("missing field_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1906,15 +1870,9 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(item)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1941,7 +1899,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { }) t.Run("missing item_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1991,23 +1949,12 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { addedItem := map[string]any{"id": 2001, "archived_at": nil} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - var payload map[string]any - _ = json.Unmarshal(body, &payload) - if payload["id"] == nil || payload["type"] == nil { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"bad request"}`)) - return - } - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(addedItem)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ + "type": "Issue", + "id": float64(123), + }).andThen(mockResponse(t, http.StatusCreated, addedItem)), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2035,7 +1982,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("missing item_type", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2057,7 +2004,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("invalid item_type", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2080,7 +2027,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("unknown method", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2107,15 +2054,9 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { updatedItem := map[string]any{"id": 1001, "archived_at": nil} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(updatedItem)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2146,7 +2087,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { }) t.Run("missing updated_field", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2172,14 +2113,11 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2203,7 +2141,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }) t.Run("missing item_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client,