Skip to content

Commit 26e3c6c

Browse files
authored
add create_issue tool (#18)
1 parent 7d762c0 commit 26e3c6c

File tree

5 files changed

+327
-3
lines changed

5 files changed

+327
-3
lines changed

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,15 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable.
2222
- `repo`: Repository name (string, required)
2323
- `issue_number`: Issue number (number, required)
2424

25+
- **create_issue** - Create a new issue in a GitHub repository
26+
27+
- `owner`: Repository owner (string, required)
28+
- `repo`: Repository name (string, required)
29+
- `title`: Issue title (string, required)
30+
- `body`: Issue body content (string, optional)
31+
- `assignees`: Comma-separated list of usernames to assign to this issue (string, optional)
32+
- `labels`: Comma-separated list of labels to apply to this issue (string, optional)
33+
2534
- **add_issue_comment** - Add a comment to an issue
2635

2736
- `owner`: Repository owner (string, required)
@@ -313,16 +322,14 @@ Lots of things!
313322
Missing tools:
314323
315324
- push_files (files array)
316-
- create_issue (assignees and labels arrays)
317325
- list_issues (labels array)
318326
- update_issue (labels and assignees arrays)
319327
- create_pull_request_review (comments array)
320328
321329
Testing
322330
323-
- Unit tests
324331
- Integration tests
325-
- Blackbox testing: ideally comparing output to Anthromorphic's server to make sure that this is a fully compatible drop-in replacement.
332+
- Blackbox testing: ideally comparing output to Anthropic's server to make sure that this is a fully compatible drop-in replacement.
326333
327334
And some other stuff:
328335

pkg/github/issues.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,3 +182,83 @@ func searchIssues(client *github.Client) (tool mcp.Tool, handler server.ToolHand
182182
return mcp.NewToolResultText(string(r)), nil
183183
}
184184
}
185+
186+
// createIssue creates a tool to create a new issue in a GitHub repository.
187+
func createIssue(client *github.Client) (tool mcp.Tool, handler server.ToolHandlerFunc) {
188+
return mcp.NewTool("create_issue",
189+
mcp.WithDescription("Create a new issue in a GitHub repository"),
190+
mcp.WithString("owner",
191+
mcp.Required(),
192+
mcp.Description("Repository owner"),
193+
),
194+
mcp.WithString("repo",
195+
mcp.Required(),
196+
mcp.Description("Repository name"),
197+
),
198+
mcp.WithString("title",
199+
mcp.Required(),
200+
mcp.Description("Issue title"),
201+
),
202+
mcp.WithString("body",
203+
mcp.Description("Issue body content"),
204+
),
205+
mcp.WithString("assignees",
206+
mcp.Description("Comma-separate list of usernames to assign to this issue"),
207+
),
208+
mcp.WithString("labels",
209+
mcp.Description("Comma-separate list of labels to apply to this issue"),
210+
),
211+
),
212+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
213+
owner := request.Params.Arguments["owner"].(string)
214+
repo := request.Params.Arguments["repo"].(string)
215+
title := request.Params.Arguments["title"].(string)
216+
217+
// Optional parameters
218+
var body string
219+
if b, ok := request.Params.Arguments["body"].(string); ok {
220+
body = b
221+
}
222+
223+
// Parse assignees if present
224+
assignees := []string{} // default to empty slice, can't be nil
225+
if a, ok := request.Params.Arguments["assignees"].(string); ok && a != "" {
226+
assignees = parseCommaSeparatedList(a)
227+
}
228+
229+
// Parse labels if present
230+
labels := []string{} // default to empty slice, can't be nil
231+
if l, ok := request.Params.Arguments["labels"].(string); ok && l != "" {
232+
labels = parseCommaSeparatedList(l)
233+
}
234+
235+
// Create the issue request
236+
issueRequest := &github.IssueRequest{
237+
Title: github.Ptr(title),
238+
Body: github.Ptr(body),
239+
Assignees: &assignees,
240+
Labels: &labels,
241+
}
242+
243+
issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest)
244+
if err != nil {
245+
return nil, fmt.Errorf("failed to create issue: %w", err)
246+
}
247+
defer func() { _ = resp.Body.Close() }()
248+
249+
if resp.StatusCode != http.StatusCreated {
250+
body, err := io.ReadAll(resp.Body)
251+
if err != nil {
252+
return nil, fmt.Errorf("failed to read response body: %w", err)
253+
}
254+
return mcp.NewToolResultError(fmt.Sprintf("failed to create issue: %s", string(body))), nil
255+
}
256+
257+
r, err := json.Marshal(issue)
258+
if err != nil {
259+
return nil, fmt.Errorf("failed to marshal response: %w", err)
260+
}
261+
262+
return mcp.NewToolResultText(string(r)), nil
263+
}
264+
}

pkg/github/issues_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,3 +369,158 @@ func Test_SearchIssues(t *testing.T) {
369369
})
370370
}
371371
}
372+
373+
func Test_CreateIssue(t *testing.T) {
374+
// Verify tool definition once
375+
mockClient := github.NewClient(nil)
376+
tool, _ := createIssue(mockClient)
377+
378+
assert.Equal(t, "create_issue", tool.Name)
379+
assert.NotEmpty(t, tool.Description)
380+
assert.Contains(t, tool.InputSchema.Properties, "owner")
381+
assert.Contains(t, tool.InputSchema.Properties, "repo")
382+
assert.Contains(t, tool.InputSchema.Properties, "title")
383+
assert.Contains(t, tool.InputSchema.Properties, "body")
384+
assert.Contains(t, tool.InputSchema.Properties, "assignees")
385+
assert.Contains(t, tool.InputSchema.Properties, "labels")
386+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"})
387+
388+
// Setup mock issue for success case
389+
mockIssue := &github.Issue{
390+
Number: github.Ptr(123),
391+
Title: github.Ptr("Test Issue"),
392+
Body: github.Ptr("This is a test issue"),
393+
State: github.Ptr("open"),
394+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
395+
Assignees: []*github.User{{Login: github.Ptr("user1")}, {Login: github.Ptr("user2")}},
396+
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("help wanted")}},
397+
}
398+
399+
tests := []struct {
400+
name string
401+
mockedClient *http.Client
402+
requestArgs map[string]interface{}
403+
expectError bool
404+
expectedIssue *github.Issue
405+
expectedErrMsg string
406+
}{
407+
{
408+
name: "successful issue creation with all fields",
409+
mockedClient: mock.NewMockedHTTPClient(
410+
mock.WithRequestMatchHandler(
411+
mock.PostReposIssuesByOwnerByRepo,
412+
mockResponse(t, http.StatusCreated, mockIssue),
413+
),
414+
),
415+
requestArgs: map[string]interface{}{
416+
"owner": "owner",
417+
"repo": "repo",
418+
"title": "Test Issue",
419+
"body": "This is a test issue",
420+
"assignees": []interface{}{"user1", "user2"},
421+
"labels": []interface{}{"bug", "help wanted"},
422+
},
423+
expectError: false,
424+
expectedIssue: mockIssue,
425+
},
426+
{
427+
name: "successful issue creation with minimal fields",
428+
mockedClient: mock.NewMockedHTTPClient(
429+
mock.WithRequestMatchHandler(
430+
mock.PostReposIssuesByOwnerByRepo,
431+
mockResponse(t, http.StatusCreated, &github.Issue{
432+
Number: github.Ptr(124),
433+
Title: github.Ptr("Minimal Issue"),
434+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
435+
State: github.Ptr("open"),
436+
}),
437+
),
438+
),
439+
requestArgs: map[string]interface{}{
440+
"owner": "owner",
441+
"repo": "repo",
442+
"title": "Minimal Issue",
443+
},
444+
expectError: false,
445+
expectedIssue: &github.Issue{
446+
Number: github.Ptr(124),
447+
Title: github.Ptr("Minimal Issue"),
448+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"),
449+
State: github.Ptr("open"),
450+
},
451+
},
452+
{
453+
name: "issue creation fails",
454+
mockedClient: mock.NewMockedHTTPClient(
455+
mock.WithRequestMatchHandler(
456+
mock.PostReposIssuesByOwnerByRepo,
457+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
458+
w.WriteHeader(http.StatusUnprocessableEntity)
459+
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
460+
}),
461+
),
462+
),
463+
requestArgs: map[string]interface{}{
464+
"owner": "owner",
465+
"repo": "repo",
466+
"title": "",
467+
},
468+
expectError: true,
469+
expectedErrMsg: "failed to create issue",
470+
},
471+
}
472+
473+
for _, tc := range tests {
474+
t.Run(tc.name, func(t *testing.T) {
475+
// Setup client with mock
476+
client := github.NewClient(tc.mockedClient)
477+
_, handler := createIssue(client)
478+
479+
// Create call request
480+
request := createMCPRequest(tc.requestArgs)
481+
482+
// Call handler
483+
result, err := handler(context.Background(), request)
484+
485+
// Verify results
486+
if tc.expectError {
487+
require.Error(t, err)
488+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
489+
return
490+
}
491+
492+
require.NoError(t, err)
493+
textContent := getTextResult(t, result)
494+
495+
// Unmarshal and verify the result
496+
var returnedIssue github.Issue
497+
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
498+
require.NoError(t, err)
499+
500+
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
501+
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
502+
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
503+
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
504+
505+
if tc.expectedIssue.Body != nil {
506+
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
507+
}
508+
509+
// Check assignees if expected
510+
if len(tc.expectedIssue.Assignees) > 0 {
511+
assert.Equal(t, len(tc.expectedIssue.Assignees), len(returnedIssue.Assignees))
512+
for i, assignee := range returnedIssue.Assignees {
513+
assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
514+
}
515+
}
516+
517+
// Check labels if expected
518+
if len(tc.expectedIssue.Labels) > 0 {
519+
assert.Equal(t, len(tc.expectedIssue.Labels), len(returnedIssue.Labels))
520+
for i, label := range returnedIssue.Labels {
521+
assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
522+
}
523+
}
524+
})
525+
}
526+
}

pkg/github/server.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"io"
99
"net/http"
10+
"strings"
1011

1112
"github.com/google/go-github/v69/github"
1213
"github.com/mark3labs/mcp-go/mcp"
@@ -34,6 +35,7 @@ func NewServer(client *github.Client) *server.MCPServer {
3435
// Add GitHub tools - Issues
3536
s.AddTool(getIssue(client))
3637
s.AddTool(addIssueComment(client))
38+
s.AddTool(createIssue(client))
3739
s.AddTool(searchIssues(client))
3840

3941
// Add GitHub tools - Pull Requests
@@ -106,3 +108,22 @@ func isAcceptedError(err error) bool {
106108
var acceptedError *github.AcceptedError
107109
return errors.As(err, &acceptedError)
108110
}
111+
112+
// parseCommaSeparatedList is a helper function that parses a comma-separated list of strings from the input string.
113+
func parseCommaSeparatedList(input string) []string {
114+
if input == "" {
115+
return nil
116+
}
117+
118+
parts := strings.Split(input, ",")
119+
result := make([]string, 0, len(parts))
120+
121+
for _, part := range parts {
122+
trimmed := strings.TrimSpace(part)
123+
if trimmed != "" {
124+
result = append(result, trimmed)
125+
}
126+
}
127+
128+
return result
129+
}

pkg/github/server_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,64 @@ func Test_IsAcceptedError(t *testing.T) {
166166
})
167167
}
168168
}
169+
170+
func Test_ParseCommaSeparatedList(t *testing.T) {
171+
tests := []struct {
172+
name string
173+
input string
174+
expected []string
175+
}{
176+
{
177+
name: "simple comma separated values",
178+
input: "one,two,three",
179+
expected: []string{"one", "two", "three"},
180+
},
181+
{
182+
name: "values with spaces",
183+
input: "one, two, three",
184+
expected: []string{"one", "two", "three"},
185+
},
186+
{
187+
name: "values with extra spaces",
188+
input: " one , two , three ",
189+
expected: []string{"one", "two", "three"},
190+
},
191+
{
192+
name: "empty values in between",
193+
input: "one,,three",
194+
expected: []string{"one", "three"},
195+
},
196+
{
197+
name: "only spaces",
198+
input: " , , ",
199+
expected: []string{},
200+
},
201+
{
202+
name: "empty string",
203+
input: "",
204+
expected: nil,
205+
},
206+
{
207+
name: "single value",
208+
input: "one",
209+
expected: []string{"one"},
210+
},
211+
{
212+
name: "trailing comma",
213+
input: "one,two,",
214+
expected: []string{"one", "two"},
215+
},
216+
{
217+
name: "leading comma",
218+
input: ",one,two",
219+
expected: []string{"one", "two"},
220+
},
221+
}
222+
223+
for _, tc := range tests {
224+
t.Run(tc.name, func(t *testing.T) {
225+
result := parseCommaSeparatedList(tc.input)
226+
assert.Equal(t, tc.expected, result)
227+
})
228+
}
229+
}

0 commit comments

Comments
 (0)