Skip to content
Open
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
136 changes: 136 additions & 0 deletions pkg/github/create_release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package github

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v74/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)

// CreateRelease creates a tool to create a new release in a GitHub repository.
func CreateRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_release",
mcp.WithDescription(t("TOOL_CREATE_RELEASE_DESCRIPTION", "Create a new release in a GitHub repository")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_CREATE_RELEASE_USER_TITLE", "Create release"),
ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithString("tag_name",
mcp.Required(),
mcp.Description("The name of the tag for this release"),
),
mcp.WithString("target_commitish",
mcp.Description("The commitish value for the tag (branch or commit SHA). Defaults to the repository's default branch"),
),
mcp.WithString("name",
mcp.Description("The name of the release"),
),
mcp.WithString("body",
mcp.Description("Text describing the contents of the release"),
),
mcp.WithBoolean("draft",
mcp.Description("Whether this is a draft (unpublished) release. Default: false"),
),
mcp.WithBoolean("prerelease",
mcp.Description("Whether this is a pre-release. Default: false"),
),
mcp.WithBoolean("generate_release_notes",
mcp.Description("Whether to automatically generate release notes from commits. Default: false"),
),
),
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
}
tagName, err := RequiredParam[string](request, "tag_name")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

// Optional parameters
targetCommitish, err := OptionalParam[string](request, "target_commitish")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
name, err := OptionalParam[string](request, "name")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
body, err := OptionalParam[string](request, "body")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
draft, err := OptionalParam[bool](request, "draft")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
prerelease, err := OptionalParam[bool](request, "prerelease")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
generateReleaseNotes, err := OptionalParam[bool](request, "generate_release_notes")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

releaseRequest := &github.RepositoryRelease{
TagName: github.Ptr(tagName),
TargetCommitish: github.Ptr(targetCommitish),
Name: github.Ptr(name),
Body: github.Ptr(body),
Draft: github.Ptr(draft),
Prerelease: github.Ptr(prerelease),
GenerateReleaseNotes: github.Ptr(generateReleaseNotes),
}

release, resp, err := client.Repositories.CreateRelease(ctx, owner, repo, releaseRequest)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to create release with tag: %s", tagName),
resp,
err,
), nil
}
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 release: %s", string(body))), nil
}

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

return mcp.NewToolResultText(string(r)), nil
}
}
157 changes: 157 additions & 0 deletions pkg/github/create_release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package github

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v74/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_CreateRelease(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper)

assert.Equal(t, "create_release", 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, "tag_name")
assert.Contains(t, tool.InputSchema.Properties, "target_commitish")
assert.Contains(t, tool.InputSchema.Properties, "name")
assert.Contains(t, tool.InputSchema.Properties, "body")
assert.Contains(t, tool.InputSchema.Properties, "draft")
assert.Contains(t, tool.InputSchema.Properties, "prerelease")
assert.Contains(t, tool.InputSchema.Properties, "generate_release_notes")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag_name"})

// Setup mock release response
mockRelease := &github.RepositoryRelease{
ID: github.Ptr(int64(1)),
TagName: github.Ptr("v1.0.0"),
Name: github.Ptr("Release v1.0.0"),
Body: github.Ptr("This is the release description"),
Draft: github.Ptr(false),
Prerelease: github.Ptr(false),
HTMLURL: github.Ptr("https://github.com/owner/repo/releases/tag/v1.0.0"),
CreatedAt: &github.Timestamp{Time: time.Now()},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedResult *github.RepositoryRelease
expectedErrMsg string
}{
{
name: "successful release creation with all parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposReleasesByOwnerByRepo,
expectRequestBody(t, map[string]interface{}{
"tag_name": "v1.0.0",
"target_commitish": "main",
"name": "Release v1.0.0",
"body": "This is the release description",
"draft": false,
"prerelease": false,
"generate_release_notes": true,
}).andThen(
mockResponse(t, http.StatusCreated, mockRelease),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"tag_name": "v1.0.0",
"target_commitish": "main",
"name": "Release v1.0.0",
"body": "This is the release description",
"draft": false,
"prerelease": false,
"generate_release_notes": true,
},
expectError: false,
expectedResult: mockRelease,
},
{
name: "successful release creation with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposReleasesByOwnerByRepo,
expectRequestBody(t, map[string]interface{}{
"tag_name": "v1.0.0",
"target_commitish": "",
"name": "",
"body": "",
"draft": false,
"prerelease": false,
"generate_release_notes": false,
}).andThen(
mockResponse(t, http.StatusCreated, mockRelease),
),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"tag_name": "v1.0.0",
},
expectError: false,
expectedResult: mockRelease,
},
{
name: "release creation fails with conflict",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.PostReposReleasesByOwnerByRepo,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation Failed", "errors": [{"code": "already_exists"}]}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"tag_name": "v1.0.0",
},
expectError: true,
expectedErrMsg: "failed to create release",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
_, handler := CreateRelease(stubGetClientFn(client), translations.NullTranslationHelper)
request := createMCPRequest(tc.requestArgs)
result, err := handler(context.Background(), request)

if tc.expectError {
require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
textContent := getTextResult(t, result)
var returnedRelease github.RepositoryRelease
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
require.NoError(t, err)
assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(CreateBranch(getClient, t)),
toolsets.NewServerTool(PushFiles(getClient, t)),
toolsets.NewServerTool(DeleteFile(getClient, t)),
toolsets.NewServerTool(CreateRelease(getClient, t)),
).
AddResourceTemplates(
toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),
Expand Down