Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pkg/github/__toolsnaps__/update_issue_type.snap
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"description": "Repository owner (username or organization)",
"type": "string"
},
"rationale": {
"description": "One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
"maxLength": 280,
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
Expand Down
114 changes: 97 additions & 17 deletions pkg/github/granular_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package github
import (
"context"
"net/http"
"strings"
"testing"

"github.com/github/github-mcp-server/internal/githubv4mock"
Expand Down Expand Up @@ -304,24 +305,103 @@ func TestGranularUpdateIssueMilestone(t *testing.T) {
}

func TestGranularUpdateIssueType(t *testing.T) {
client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
"type": "bug",
}).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
}))
deps := BaseDeps{Client: client}
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)
tests := []struct {
name string
requestArgs map[string]any
expectedReq map[string]any
}{
{
name: "type only",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
},
expectedReq: map[string]any{
"type": "bug",
},
},
{
name: "type with rationale",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": " This issue requests a new capability ",
},
expectedReq: map[string]any{
"type": map[string]any{
"value": "feature",
"rationale": "This issue requests a new capability",
},
},
},
}

request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "bug",
})
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError)
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq).
andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})),
}))
deps := BaseDeps{Client: client}
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
assert.False(t, result.IsError)
})
}
}

func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) {
tests := []struct {
name string
requestArgs map[string]any
expectedErrText string
}{
{
name: "rationale wrong type",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": float64(123),
},
expectedErrText: "parameter rationale is not of type string, is float64",
},
{
name: "rationale too long",
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"issue_number": float64(1),
"issue_type": "feature",
"rationale": strings.Repeat("a", 281),
},
expectedErrText: "parameter rationale must be 280 characters or less",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
deps := BaseDeps{Client: gogithub.NewClient(MockHTTPClientWithHandlers(nil))}
serverTool := GranularUpdateIssueType(translations.NullTranslationHelper)
handler := serverTool.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)

errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrText)
})
}
}

func TestGranularUpdateIssueState(t *testing.T) {
Expand Down
128 changes: 116 additions & 12 deletions pkg/github/issues_granular.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,27 +309,131 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor
)
}

// issueTypeWithRationale represents the object form of the issue type field,
// allowing a rationale to be sent alongside the type name.
type issueTypeWithRationale struct {
Value string `json:"value"`
Rationale string `json:"rationale"`
}

// issueTypeUpdateRequest is a custom request body for updating an issue type
// with an optional rationale, using the object form that the REST API accepts.
type issueTypeUpdateRequest struct {
Type issueTypeWithRationale `json:"type"`
}

// GranularUpdateIssueType creates a tool to update an issue's type.
func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool {
return issueUpdateTool(t,
"update_issue_type",
"Update the type of an existing issue (e.g. 'bug', 'feature').",
"Update Issue Type",
map[string]*jsonschema.Schema{
"issue_type": {
Type: "string",
Description: "The issue type to set",
st := NewTool(
ToolsetMetadataIssues,
mcp.Tool{
Name: "update_issue_type",
Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"),
ReadOnlyHint: false,
DestructiveHint: jsonschema.Ptr(false),
OpenWorldHint: jsonschema.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"issue_number": {
Type: "number",
Description: "The issue number to update",
Minimum: jsonschema.Ptr(1.0),
},
"issue_type": {
Type: "string",
Description: "The issue type to set",
},
"rationale": {
Type: "string",
Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " +
"State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).",
MaxLength: jsonschema.Ptr(280),
},
},
Required: []string{"owner", "repo", "issue_number", "issue_type"},
},
},
[]string{"issue_type"},
func(args map[string]any) (*github.IssueRequest, error) {
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueNumber, err := RequiredInt(args, "issue_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
issueType, err := RequiredParam[string](args, "issue_type")
if err != nil {
return nil, err
return utils.NewToolResultError(err.Error()), nil, nil
}
rationale, err := OptionalParam[string](args, "rationale")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
rationale = strings.TrimSpace(rationale)
if len([]rune(rationale)) > 280 {
return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}

var body any
if rationale != "" {
body = &issueTypeUpdateRequest{
Type: issueTypeWithRationale{
Value: issueType,
Rationale: rationale,
},
Comment on lines +400 to +406
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 5cfd36a.

}
} else {
body = &github.IssueRequest{Type: &issueType}
}

apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
req, err := client.NewRequest("PATCH", apiURL, body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
}
return &github.IssueRequest{Type: &issueType}, nil

issue := &github.Issue{}
resp, err := client.Do(ctx, req, issue)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil
}
defer func() { _ = resp.Body.Close() }()

r, err := json.Marshal(MinimalResponse{
ID: fmt.Sprintf("%d", issue.GetID()),
URL: issue.GetHTMLURL(),
})
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}
return utils.NewToolResultText(string(r)), nil, nil
},
)
st.FeatureFlagEnable = FeatureFlagIssuesGranular
return st
}

// GranularUpdateIssueState creates a tool to update an issue's state.
Expand Down