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) + }) + } +} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

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:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy