diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap new file mode 100644 index 000000000..93c3e51d9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "title": "List available issue types", + "readOnlyHint": true + }, + "description": "List supported issue types for repository owner (organization).", + "inputSchema": { + "properties": { + "owner": { + "description": "The organization owner of the repository", + "type": "string" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_types" +} \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3242c2be9..c7c18372b 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -79,6 +79,53 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool } } +// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. +func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + + return mcp.NewTool("list_issue_types", + mcp.WithDescription(t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization).")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_ISSUE_TYPES_USER_TITLE", "List available issue types"), + ReadOnlyHint: ToBoolPtr(true), + }), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("The organization owner of the repository"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := RequiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return nil, fmt.Errorf("failed to list issue types: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue types: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("add_issue_comment", diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 056fa7ed8..2f4b1818f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "testing" "time" @@ -1629,3 +1630,146 @@ func TestAssignCopilotToIssue(t *testing.T) { }) } } + +func Test_ListIssueTypes(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListIssueTypes(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_types", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner"}) + + // Setup mock issue types for success case + mockIssueTypes := []*github.IssueType{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("bug"), + Description: github.Ptr("Something isn't working"), + Color: github.Ptr("d73a4a"), + }, + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("feature"), + Description: github.Ptr("New feature or enhancement"), + Color: github.Ptr("a2eeef"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssueTypes []*github.IssueType + expectedErrMsg string + }{ + { + name: "successful issue types retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "testorg", + }, + expectError: false, + expectedIssueTypes: mockIssueTypes, + }, + { + name: "organization not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/nonexistent/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "missing owner parameter", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/orgs/testorg/issue-types", + Method: "GET", + }, + mockResponse(t, http.StatusOK, mockIssueTypes), + ), + ), + requestArgs: map[string]interface{}{}, + expectError: false, // This should be handled by parameter validation, error returned in result + expectedErrMsg: "missing required parameter: owner", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListIssueTypes(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + // Check if error is returned as tool result error + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + // Check if it's a parameter validation error (returned as tool result error) + if result != nil && result.IsError { + errorContent := getErrorResult(t, result) + if tc.expectedErrMsg != "" && strings.Contains(errorContent.Text, tc.expectedErrMsg) { + return // This is expected for parameter validation errors + } + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssueTypes []*github.IssueType + err = json.Unmarshal([]byte(textContent.Text), &returnedIssueTypes) + require.NoError(t, err) + + if tc.expectedIssueTypes != nil { + require.Equal(t, len(tc.expectedIssueTypes), len(returnedIssueTypes)) + for i, expected := range tc.expectedIssueTypes { + assert.Equal(t, *expected.Name, *returnedIssueTypes[i].Name) + assert.Equal(t, *expected.Description, *returnedIssueTypes[i].Description) + assert.Equal(t, *expected.Color, *returnedIssueTypes[i].Color) + assert.Equal(t, *expected.ID, *returnedIssueTypes[i].ID) + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 76b31d477..e9a1de7ad 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -53,6 +53,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(SearchIssues(getClient, t)), toolsets.NewServerTool(ListIssues(getClient, t)), toolsets.NewServerTool(GetIssueComments(getClient, t)), + toolsets.NewServerTool(ListIssueTypes(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(CreateIssue(getClient, t)),
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: