Skip to content

Commit 5f5a0a5

Browse files
committed
add support for the update_issue tool
1 parent 14fb02b commit 5f5a0a5

File tree

4 files changed

+305
-2
lines changed

4 files changed

+305
-2
lines changed

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ and set it as the GITHUB_PERSONAL_ACCESS_TOKEN environment variable.
5050
- `page`: Page number (number, optional)
5151
- `per_page`: Results per page (number, optional)
5252

53+
- **update_issue** - Update an existing issue in a GitHub repository
54+
55+
- `owner`: Repository owner (string, required)
56+
- `repo`: Repository name (string, required)
57+
- `issue_number`: Issue number to update (number, required)
58+
- `title`: New title (string, optional)
59+
- `body`: New description (string, optional)
60+
- `state`: New state ('open' or 'closed') (string, optional)
61+
- `labels`: Comma-separated list of new labels (string, optional)
62+
- `assignees`: Comma-separated list of new assignees (string, optional)
63+
- `milestone`: New milestone number (number, optional)
64+
5365
- **search_issues** - Search for issues and pull requests
5466
- `query`: Search query (string, required)
5567
- `sort`: Sort field (string, optional)
@@ -368,8 +380,6 @@ Lots of things!
368380
Missing tools:
369381
370382
- push_files (files array)
371-
- list_issues (labels array)
372-
- update_issue (labels and assignees arrays)
373383
- create_pull_request_review (comments array)
374384
375385
Testing

pkg/github/issues.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,100 @@ func listIssues(client *github.Client, t translations.TranslationHelperFunc) (to
361361
}
362362
}
363363

364+
// updateIssue creates a tool to update an existing issue in a GitHub repository.
365+
func updateIssue(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
366+
return mcp.NewTool("update_issue",
367+
mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository")),
368+
mcp.WithString("owner",
369+
mcp.Required(),
370+
mcp.Description("Repository owner"),
371+
),
372+
mcp.WithString("repo",
373+
mcp.Required(),
374+
mcp.Description("Repository name"),
375+
),
376+
mcp.WithNumber("issue_number",
377+
mcp.Required(),
378+
mcp.Description("Issue number to update"),
379+
),
380+
mcp.WithString("title",
381+
mcp.Description("New title"),
382+
),
383+
mcp.WithString("body",
384+
mcp.Description("New description"),
385+
),
386+
mcp.WithString("state",
387+
mcp.Description("New state ('open' or 'closed')"),
388+
),
389+
mcp.WithString("labels",
390+
mcp.Description("Comma-separated list of new labels"),
391+
),
392+
mcp.WithString("assignees",
393+
mcp.Description("Comma-separated list of new assignees"),
394+
),
395+
mcp.WithNumber("milestone",
396+
mcp.Description("New milestone number"),
397+
),
398+
),
399+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
400+
owner := request.Params.Arguments["owner"].(string)
401+
repo := request.Params.Arguments["repo"].(string)
402+
issueNumber := int(request.Params.Arguments["issue_number"].(float64))
403+
404+
// Create the issue request with only provided fields
405+
issueRequest := &github.IssueRequest{}
406+
407+
// Set optional parameters if provided
408+
if title, ok := request.Params.Arguments["title"].(string); ok && title != "" {
409+
issueRequest.Title = github.Ptr(title)
410+
}
411+
412+
if body, ok := request.Params.Arguments["body"].(string); ok && body != "" {
413+
issueRequest.Body = github.Ptr(body)
414+
}
415+
416+
if state, ok := request.Params.Arguments["state"].(string); ok && state != "" {
417+
issueRequest.State = github.Ptr(state)
418+
}
419+
420+
if labels, ok := request.Params.Arguments["labels"].(string); ok && labels != "" {
421+
labelsList := parseCommaSeparatedList(labels)
422+
issueRequest.Labels = &labelsList
423+
}
424+
425+
if assignees, ok := request.Params.Arguments["assignees"].(string); ok && assignees != "" {
426+
assigneesList := parseCommaSeparatedList(assignees)
427+
issueRequest.Assignees = &assigneesList
428+
}
429+
430+
if milestone, ok := request.Params.Arguments["milestone"].(float64); ok {
431+
milestoneNum := int(milestone)
432+
issueRequest.Milestone = &milestoneNum
433+
}
434+
435+
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
436+
if err != nil {
437+
return nil, fmt.Errorf("failed to update issue: %w", err)
438+
}
439+
defer func() { _ = resp.Body.Close() }()
440+
441+
if resp.StatusCode != http.StatusOK {
442+
body, err := io.ReadAll(resp.Body)
443+
if err != nil {
444+
return nil, fmt.Errorf("failed to read response body: %w", err)
445+
}
446+
return mcp.NewToolResultError(fmt.Sprintf("failed to update issue: %s", string(body))), nil
447+
}
448+
449+
r, err := json.Marshal(updatedIssue)
450+
if err != nil {
451+
return nil, fmt.Errorf("failed to marshal response: %w", err)
452+
}
453+
454+
return mcp.NewToolResultText(string(r)), nil
455+
}
456+
}
457+
364458
// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
365459
// Returns the parsed time or an error if parsing fails.
366460
// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"

pkg/github/issues_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,6 +693,204 @@ func Test_ListIssues(t *testing.T) {
693693
}
694694
}
695695

696+
func Test_UpdateIssue(t *testing.T) {
697+
// Verify tool definition
698+
mockClient := github.NewClient(nil)
699+
tool, _ := updateIssue(mockClient, translations.NullTranslationHelper)
700+
701+
assert.Equal(t, "update_issue", tool.Name)
702+
assert.NotEmpty(t, tool.Description)
703+
assert.Contains(t, tool.InputSchema.Properties, "owner")
704+
assert.Contains(t, tool.InputSchema.Properties, "repo")
705+
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
706+
assert.Contains(t, tool.InputSchema.Properties, "title")
707+
assert.Contains(t, tool.InputSchema.Properties, "body")
708+
assert.Contains(t, tool.InputSchema.Properties, "state")
709+
assert.Contains(t, tool.InputSchema.Properties, "labels")
710+
assert.Contains(t, tool.InputSchema.Properties, "assignees")
711+
assert.Contains(t, tool.InputSchema.Properties, "milestone")
712+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
713+
714+
// Setup mock issue for success case
715+
mockIssue := &github.Issue{
716+
Number: github.Ptr(123),
717+
Title: github.Ptr("Updated Issue Title"),
718+
Body: github.Ptr("Updated issue description"),
719+
State: github.Ptr("closed"),
720+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
721+
Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}},
722+
Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}},
723+
Milestone: &github.Milestone{Number: github.Ptr(5)},
724+
}
725+
726+
tests := []struct {
727+
name string
728+
mockedClient *http.Client
729+
requestArgs map[string]interface{}
730+
expectError bool
731+
expectedIssue *github.Issue
732+
expectedErrMsg string
733+
}{
734+
{
735+
name: "update issue with all fields",
736+
mockedClient: mock.NewMockedHTTPClient(
737+
mock.WithRequestMatchHandler(
738+
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
739+
mockResponse(t, http.StatusOK, mockIssue),
740+
),
741+
),
742+
requestArgs: map[string]interface{}{
743+
"owner": "owner",
744+
"repo": "repo",
745+
"issue_number": float64(123),
746+
"title": "Updated Issue Title",
747+
"body": "Updated issue description",
748+
"state": "closed",
749+
"labels": "bug,priority",
750+
"assignees": "assignee1,assignee2",
751+
"milestone": float64(5),
752+
},
753+
expectError: false,
754+
expectedIssue: mockIssue,
755+
},
756+
{
757+
name: "update issue with minimal fields",
758+
mockedClient: mock.NewMockedHTTPClient(
759+
mock.WithRequestMatchHandler(
760+
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
761+
mockResponse(t, http.StatusOK, &github.Issue{
762+
Number: github.Ptr(123),
763+
Title: github.Ptr("Only Title Updated"),
764+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
765+
State: github.Ptr("open"),
766+
}),
767+
),
768+
),
769+
requestArgs: map[string]interface{}{
770+
"owner": "owner",
771+
"repo": "repo",
772+
"issue_number": float64(123),
773+
"title": "Only Title Updated",
774+
},
775+
expectError: false,
776+
expectedIssue: &github.Issue{
777+
Number: github.Ptr(123),
778+
Title: github.Ptr("Only Title Updated"),
779+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"),
780+
State: github.Ptr("open"),
781+
},
782+
},
783+
{
784+
name: "update issue fails with not found",
785+
mockedClient: mock.NewMockedHTTPClient(
786+
mock.WithRequestMatchHandler(
787+
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
788+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
789+
w.WriteHeader(http.StatusNotFound)
790+
_, _ = w.Write([]byte(`{"message": "Issue not found"}`))
791+
}),
792+
),
793+
),
794+
requestArgs: map[string]interface{}{
795+
"owner": "owner",
796+
"repo": "repo",
797+
"issue_number": float64(999),
798+
"title": "This issue doesn't exist",
799+
},
800+
expectError: true,
801+
expectedErrMsg: "failed to update issue",
802+
},
803+
{
804+
name: "update issue fails with validation error",
805+
mockedClient: mock.NewMockedHTTPClient(
806+
mock.WithRequestMatchHandler(
807+
mock.PatchReposIssuesByOwnerByRepoByIssueNumber,
808+
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
809+
w.WriteHeader(http.StatusUnprocessableEntity)
810+
_, _ = w.Write([]byte(`{"message": "Invalid state value"}`))
811+
}),
812+
),
813+
),
814+
requestArgs: map[string]interface{}{
815+
"owner": "owner",
816+
"repo": "repo",
817+
"issue_number": float64(123),
818+
"state": "invalid_state",
819+
},
820+
expectError: true,
821+
expectedErrMsg: "failed to update issue",
822+
},
823+
}
824+
825+
for _, tc := range tests {
826+
t.Run(tc.name, func(t *testing.T) {
827+
// Setup client with mock
828+
client := github.NewClient(tc.mockedClient)
829+
_, handler := updateIssue(client, translations.NullTranslationHelper)
830+
831+
// Create call request
832+
request := createMCPRequest(tc.requestArgs)
833+
834+
// Call handler
835+
result, err := handler(context.Background(), request)
836+
837+
// Verify results
838+
if tc.expectError {
839+
if err != nil {
840+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
841+
} else {
842+
// For errors returned as part of the result, not as an error
843+
require.NotNil(t, result)
844+
textContent := getTextResult(t, result)
845+
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
846+
}
847+
return
848+
}
849+
850+
require.NoError(t, err)
851+
852+
// Parse the result and get the text content if no error
853+
textContent := getTextResult(t, result)
854+
855+
// Unmarshal and verify the result
856+
var returnedIssue github.Issue
857+
err = json.Unmarshal([]byte(textContent.Text), &returnedIssue)
858+
require.NoError(t, err)
859+
860+
assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number)
861+
assert.Equal(t, *tc.expectedIssue.Title, *returnedIssue.Title)
862+
assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State)
863+
assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL)
864+
865+
if tc.expectedIssue.Body != nil {
866+
assert.Equal(t, *tc.expectedIssue.Body, *returnedIssue.Body)
867+
}
868+
869+
// Check assignees if expected
870+
if tc.expectedIssue.Assignees != nil && len(tc.expectedIssue.Assignees) > 0 {
871+
assert.Len(t, returnedIssue.Assignees, len(tc.expectedIssue.Assignees))
872+
for i, assignee := range returnedIssue.Assignees {
873+
assert.Equal(t, *tc.expectedIssue.Assignees[i].Login, *assignee.Login)
874+
}
875+
}
876+
877+
// Check labels if expected
878+
if tc.expectedIssue.Labels != nil && len(tc.expectedIssue.Labels) > 0 {
879+
assert.Len(t, returnedIssue.Labels, len(tc.expectedIssue.Labels))
880+
for i, label := range returnedIssue.Labels {
881+
assert.Equal(t, *tc.expectedIssue.Labels[i].Name, *label.Name)
882+
}
883+
}
884+
885+
// Check milestone if expected
886+
if tc.expectedIssue.Milestone != nil {
887+
assert.NotNil(t, returnedIssue.Milestone)
888+
assert.Equal(t, *tc.expectedIssue.Milestone.Number, *returnedIssue.Milestone.Number)
889+
}
890+
})
891+
}
892+
}
893+
696894
func Test_ParseISOTimestamp(t *testing.T) {
697895
tests := []struct {
698896
name string

pkg/github/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH
4141
s.AddTool(createIssue(client, t))
4242
s.AddTool(addIssueComment(client, t))
4343
s.AddTool(createIssue(client, t))
44+
s.AddTool(updateIssue(client, t))
4445
}
4546

4647
// Add GitHub tools - Pull Requests

0 commit comments

Comments
 (0)