Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
46 changes: 46 additions & 0 deletions pkg/github/__toolsnaps__/create_milestone.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"annotations": {
"title": "Create new milestone",
"readOnlyHint": false
},
"description": "Create a new milestone in a GitHub repository.",
"inputSchema": {
"properties": {
"description": {
"description": "Milestone description",
"type": "string"
},
"due_on": {
"description": "Milestone due date in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)",
"type": "string"
},
"owner": {
"description": "Repository owner",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
},
"state": {
"description": "Milestone state",
"enum": [
"open",
"closed"
],
"type": "string"
},
"title": {
"description": "Milestone title",
"type": "string"
}
},
"required": [
"owner",
"repo",
"title"
],
"type": "object"
},
"name": "create_milestone"
}
100 changes: 100 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,106 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
}
}

// CreateMilestone creates a tool to create a new milestone in a GitHub repository.
func CreateMilestone(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_milestone",
mcp.WithDescription(t("TOOL_CREATE_MILESTONE_DESCRIPTION", "Create a new milestone in a GitHub repository.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_CREATE_MILESTONE_USER_TITLE", "Create new milestone"),
ReadOnlyHint: ToBoolPtr(false),
}),
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("Milestone title"),
),
mcp.WithString("state",
mcp.Description("Milestone state"),
mcp.Enum("open", "closed"),
),
mcp.WithString("description",
mcp.Description("Milestone description"),
),
mcp.WithString("due_on",
mcp.Description("Milestone due date in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
title, err := RequiredParam[string](request, "title")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Optional parameters
state, err := OptionalParam[string](request, "state")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
description, err := OptionalParam[string](request, "description")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
dueOn, err := OptionalParam[string](request, "due_on")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

milestoneRequest := &github.Milestone{
Title: github.Ptr(title),
State: github.Ptr(state),
Description: github.Ptr(description),
}

if dueOn != "" {
dueOnTime, err := time.Parse(time.RFC3339, dueOn)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("invalid due_on format: %v", err)), nil
}
milestoneRequest.DueOn = &github.Timestamp{Time: dueOnTime}
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
milestone, resp, err := client.Issues.CreateMilestone(ctx, owner, repo, milestoneRequest)
if err != nil {
return nil, fmt.Errorf("failed to create milestone: %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 milestone: %s", string(body))), nil
}

r, err := json.Marshal(milestone)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues.
func ListIssueTypes(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {

Expand Down
131 changes: 131 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,137 @@ func Test_GetIssue(t *testing.T) {
}
}

func Test_CreateMilestone(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateMilestone(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "create_milestone", 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, "state")
assert.Contains(t, tool.InputSchema.Properties, "description")
assert.Contains(t, tool.InputSchema.Properties, "due_on")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "title"})

// Setup mock milestone for success case
dueOn, _ := time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")
mockMilestone := &github.Milestone{
Number: github.Ptr(1),
Title: github.Ptr("Test Milestone"),
Description: github.Ptr("This is a test milestone"),
State: github.Ptr("open"),
HTMLURL: github.Ptr("https://github.com/owner/repo/milestones/1"),
DueOn: &github.Timestamp{Time: dueOn},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedMilestone *github.Milestone
expectedErrMsg string
}{
{
name: "successful milestone creation with all fields",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposMilestonesByOwnerByRepo,
expectRequestBody(t, map[string]any{
"title": "Test Milestone",
"state": "open",
"description": "This is a test milestone",
"due_on": "2025-01-01T00:00:00Z",
}).andThen(
mockResponse(t, http.StatusCreated, mockMilestone),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "Test Milestone",
"state": "open",
"description": "This is a test milestone",
"due_on": "2025-01-01T00:00:00Z",
},
expectError: false,
expectedMilestone: mockMilestone,
},
{
name: "milestone creation fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposMilestonesByOwnerByRepo,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation failed"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"title": "",
},
expectError: false,
expectedErrMsg: "missing required parameter: title",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := CreateMilestone(stubGetClientFn(client), translations.NullTranslationHelper)

// 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
}

if tc.expectedErrMsg != "" {
require.NotNil(t, result)
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
textContent := getTextResult(t, result)

// Unmarshal and verify the result
var returnedMilestone github.Milestone
err = json.Unmarshal([]byte(textContent.Text), &returnedMilestone)
require.NoError(t, err)

assert.Equal(t, *tc.expectedMilestone.Number, *returnedMilestone.Number)
assert.Equal(t, *tc.expectedMilestone.Title, *returnedMilestone.Title)
assert.Equal(t, *tc.expectedMilestone.State, *returnedMilestone.State)
assert.Equal(t, *tc.expectedMilestone.HTMLURL, *returnedMilestone.HTMLURL)

if tc.expectedMilestone.Description != nil {
assert.Equal(t, *tc.expectedMilestone.Description, *returnedMilestone.Description)
}
if tc.expectedMilestone.DueOn != nil {
assert.Equal(t, tc.expectedMilestone.DueOn.Time.UTC(), returnedMilestone.DueOn.Time.UTC())
}
})
}
}

func Test_AddIssueComment(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
).
AddWriteTools(
toolsets.NewServerTool(CreateIssue(getClient, t)),
toolsets.NewServerTool(CreateMilestone(getClient, t)),
toolsets.NewServerTool(AddIssueComment(getClient, t)),
toolsets.NewServerTool(UpdateIssue(getClient, t)),
toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
Expand Down