Skip to content

Commit faa2366

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

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)
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