From 29373fdb355672afe2decfc14c207d5c2e1dbae2 Mon Sep 17 00:00:00 2001 From: Alon Kenneth <11458012+akenneth@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:16:50 +0000 Subject: [PATCH 1/3] add list issue types action --- .../__toolsnaps__/list_issue_types.snap | 20 +++ pkg/github/issues.go | 49 ++++++ pkg/github/issues_test.go | 144 ++++++++++++++++++ pkg/github/tools.go | 1 + 4 files changed, 214 insertions(+) create mode 100644 pkg/github/__toolsnaps__/list_issue_types.snap 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..5f5d12754 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "strings" "time" @@ -19,6 +20,7 @@ import ( // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + log.Println("Fetching issue from GitHub:") return mcp.NewTool("get_issue", mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ @@ -79,6 +81,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) + } + issue_types, 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(issue_types) + 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)), From e5d77c3246c3004c2635c89237ee091e2adf0928 Mon Sep 17 00:00:00 2001 From: Alon Kenneth <11458012+akenneth@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:26:43 +0000 Subject: [PATCH 2/3] lint --- pkg/github/issues.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 5f5d12754..58210f308 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -83,7 +83,7 @@ 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{ @@ -105,7 +105,7 @@ func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - issue_types, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) if err != nil { return nil, fmt.Errorf("failed to list issue types: %w", err) } @@ -119,7 +119,7 @@ func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) return mcp.NewToolResultError(fmt.Sprintf("failed to list issue types: %s", string(body))), nil } - r, err := json.Marshal(issue_types) + r, err := json.Marshal(issueTypes) if err != nil { return nil, fmt.Errorf("failed to marshal issue types: %w", err) } From 07791696451c2305805c5caf058748ca11c8a4f0 Mon Sep 17 00:00:00 2001 From: Alon Kenneth <11458012+akenneth@users.noreply.github.com> Date: Mon, 30 Jun 2025 17:40:52 +0000 Subject: [PATCH 3/3] remove log --- pkg/github/issues.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 58210f308..c7c18372b 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" "strings" "time" @@ -20,7 +19,6 @@ import ( // GetIssue creates a tool to get details of a specific issue in a GitHub repository. func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { - log.Println("Fetching issue from GitHub:") return mcp.NewTool("get_issue", mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")), mcp.WithToolAnnotation(mcp.ToolAnnotation{