From 31e72befa04d25cba5d0e8a3457a1563c54e194d Mon Sep 17 00:00:00 2001 From: Javier Uruen Val Date: Tue, 18 Mar 2025 15:33:39 +0100 Subject: [PATCH] add create_issue tool --- README.md | 13 +++- pkg/github/issues.go | 80 ++++++++++++++++++++ pkg/github/issues_test.go | 155 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 21 ++++++ pkg/github/server_test.go | 61 +++++++++++++++ 5 files changed, 327 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 582c2a1c1..632e080d4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,15 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable. - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **create_issue** - Create a new issue in a GitHub repository + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + - `body`: Issue body content (string, optional) + - `assignees`: Comma-separated list of usernames to assign to this issue (string, optional) + - `labels`: Comma-separated list of labels to apply to this issue (string, optional) + - **add_issue_comment** - Add a comment to an issue - `owner`: Repository owner (string, required) @@ -263,16 +272,14 @@ Lots of things! Missing tools: - push_files (files array) -- create_issue (assignees and labels arrays) - list_issues (labels array) - update_issue (labels and assignees arrays) - create_pull_request_review (comments array) Testing -- Unit tests - Integration tests -- Blackbox testing: ideally comparing output to Anthromorphic's server to make sure that this is a fully compatible drop-in replacement. +- Blackbox testing: ideally comparing output to Anthropic's server to make sure that this is a fully compatible drop-in replacement. And some other stuff: diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6a43e59d5..4780a4b11 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -182,3 +182,83 @@ func searchIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHand return mcp.NewToolResultText(string(r)), nil } } + +// createIssue creates a tool to create a new issue in a GitHub repository. +func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_issue", + mcp.WithDescription("Create a new issue in a GitHub repository"), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("title", + mcp.Required(), + mcp.Description("Issue title"), + ), + mcp.WithString("body", + mcp.Description("Issue body content"), + ), + mcp.WithString("assignees", + mcp.Description("Comma-separate list of usernames to assign to this issue"), + ), + mcp.WithString("labels", + mcp.Description("Comma-separate list of labels to apply to this issue"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner := request.Params.Arguments["owner"].(string) + repo := request.Params.Arguments["repo"].(string) + title := request.Params.Arguments["title"].(string) + + // Optional parameters + var body string + if b, ok := request.Params.Arguments["body"].(string); ok { + body = b + } + + // Parse assignees if present + assignees := []string{} // default to empty slice, can't be nil + if a, ok := request.Params.Arguments["assignees"].(string); ok && a != "" { + assignees = parseCommaSeparatedList(a) + } + + // Parse labels if present + labels := []string{} // default to empty slice, can't be nil + if l, ok := request.Params.Arguments["labels"].(string); ok && l != "" { + labels = parseCommaSeparatedList(l) + } + + // Create the issue request + issueRequest := &github.IssueRequest{ + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + } + + issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) + if err != nil { + return nil, fmt.Errorf("failed to create issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil + } + + r, err := json.Marshal(issue) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7e9944b35..c1ebf6d01 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -369,3 +369,158 @@ func Test_SearchIssues(t *testing.T) { }) } } + +func Test_CreateIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := createIssue(mockClient) + + assert.Equal(t, "create_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "title") + assert.Contains(t, tool.InputSchema.Properties, "body") + assert.Contains(t, tool.InputSchema.Properties, "assignees") + assert.Contains(t, tool.InputSchema.Properties, "labels") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"}) + + // Setup mock issue for success case + mockIssue := &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Test Issue"), + Body: github.Ptr("This is a test issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}}, + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssue *github.Issue + expectedErrMsg string + }{ + { + name: "successful issue creation with all fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + mockResponse(t, http.StatusCreated, mockIssue), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "Test Issue", + "body": "This is a test issue", + "assignees": []interface{}{"user1", "user2"}, + "labels": []interface{}{"bug", "help wanted"}, + }, + expectError: false, + expectedIssue: mockIssue, + }, + { + name: "successful issue creation with minimal fields", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "Minimal Issue", + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }, + }, + { + name: "issue creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.PostReposIssuesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "title": "", + }, + expectError: true, + expectedErrMsg: "failed to create issue", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := createIssue(client) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssue github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) + assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title) + assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) + assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) + + if tc.expectedIssue.Body != nil { + assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body) + } + + // Check assignees if expected + if len(tc.expectedIssue.Assignees) > 0 { + assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees)) + for i, assignee := range returnedIssue.Assignees { + assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login) + } + } + + // Check labels if expected + if len(tc.expectedIssue.Labels) > 0 { + assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels)) + for i, label := range returnedIssue.Labels { + assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name) + } + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 0a90b4d1b..3a4d78cc7 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" @@ -25,6 +26,7 @@ func NewServer(client *github.Client) *server.MCPServer { // Add GitHub tools - Issues s.AddTool(getIssue(client)) s.AddTool(addIssueComment(client)) + s.AddTool(createIssue(client)) s.AddTool(searchIssues(client)) // Add GitHub tools - Pull Requests @@ -97,3 +99,22 @@ func isAcceptedError(err error) bool { var acceptedError *github.AcceptedError return errors.As(err, &acceptedError) } + +// parseCommaSeparatedList is a helper function that parses a comma-separated list of strings from the input string. +func parseCommaSeparatedList(input string) []string { + if input == "" { + return nil + } + + parts := strings.Split(input, ",") + result := make([]string, 0, len(parts)) + + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + + return result +} diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index d56993ded..5515c8814 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -166,3 +166,64 @@ func Test_IsAcceptedError(t *testing.T) { }) } } + +func Test_ParseCommaSeparatedList(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "simple comma separated values", + input: "one,two,three", + expected: []string{"one", "two", "three"}, + }, + { + name: "values with spaces", + input: "one, two, three", + expected: []string{"one", "two", "three"}, + }, + { + name: "values with extra spaces", + input: " one , two , three ", + expected: []string{"one", "two", "three"}, + }, + { + name: "empty values in between", + input: "one,,three", + expected: []string{"one", "three"}, + }, + { + name: "only spaces", + input: " , , ", + expected: []string{}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "single value", + input: "one", + expected: []string{"one"}, + }, + { + name: "trailing comma", + input: "one,two,", + expected: []string{"one", "two"}, + }, + { + name: "leading comma", + input: ",one,two", + expected: []string{"one", "two"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := parseCommaSeparatedList(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +}