From 63e98742ecc7be5c56fc4ae3948ef777401d5552 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 30 Aug 2025 09:22:50 -0500 Subject: [PATCH 1/3] feat: add CreateRelease tool for GitHub releases Implements a new CreateRelease tool following the existing pattern used by CreateRepository and other create operations. Features: - Creates GitHub releases with tag name, target commitish, and release notes - Supports draft and prerelease flags - Supports automatic release notes generation - Comprehensive test coverage following existing test patterns Closes #1012 --- pkg/github/create_release.go | 136 ++++++++++++++++++++++++++ pkg/github/create_release_test.go | 157 ++++++++++++++++++++++++++++++ pkg/github/tools.go | 1 + 3 files changed, 294 insertions(+) create mode 100644 pkg/github/create_release.go create mode 100644 pkg/github/create_release_test.go diff --git a/pkg/github/create_release.go b/pkg/github/create_release.go new file mode 100644 index 000000000..af7fa37ad --- /dev/null +++ b/pkg/github/create_release.go @@ -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 + } +} \ No newline at end of file diff --git a/pkg/github/create_release_test.go b/pkg/github/create_release_test.go new file mode 100644 index 000000000..5e53f8c01 --- /dev/null +++ b/pkg/github/create_release_test.go @@ -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) + }) + } +} \ No newline at end of file diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 728d78097..2654b4ba8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -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)), From 428646730b64182fa260f45000d83757d866cd23 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 30 Aug 2025 11:11:51 -0500 Subject: [PATCH 2/3] fix: defer response body close after reading Addresses Copilot review comment - the response body was being closed before attempting to read it in the error case. Now we only close it after reading when needed. --- pkg/github/create_release.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/github/create_release.go b/pkg/github/create_release.go index af7fa37ad..79d865e1c 100644 --- a/pkg/github/create_release.go +++ b/pkg/github/create_release.go @@ -116,15 +116,16 @@ func CreateRelease(getClient GetClientFn, t translations.TranslationHelperFunc) 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) } + defer func() { _ = resp.Body.Close() }() return mcp.NewToolResultError(fmt.Sprintf("failed to create release: %s", string(body))), nil } + defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(release) if err != nil { From dad963399d69f8aa3618962b48eecf510c569172 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Sat, 30 Aug 2025 12:32:20 -0500 Subject: [PATCH 3/3] fix: properly handle response body closing Put single defer statement right after successful API call to ensure body is closed in all paths. Removes duplicate defer statements. --- pkg/github/create_release.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/github/create_release.go b/pkg/github/create_release.go index 79d865e1c..af7fa37ad 100644 --- a/pkg/github/create_release.go +++ b/pkg/github/create_release.go @@ -116,16 +116,15 @@ func CreateRelease(getClient GetClientFn, t translations.TranslationHelperFunc) 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) } - defer func() { _ = resp.Body.Close() }() return mcp.NewToolResultError(fmt.Sprintf("failed to create release: %s", string(body))), nil } - defer func() { _ = resp.Body.Close() }() r, err := json.Marshal(release) if err != nil {