From 42c575895c739b11dded4c9b1d1667b8e5aa2b38 Mon Sep 17 00:00:00 2001 From: Ueslei Santos Lima Date: Tue, 20 May 2025 17:15:09 +0200 Subject: [PATCH] Including new `create_repository_using_template` tool --- README.md | 8 ++ pkg/github/repositories.go | 90 ++++++++++++++++++ pkg/github/repositories_test.go | 160 +++++++++++++++++++++++++++++++- 3 files changed, 256 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 352bb50e..5afabdb3 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,14 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `private`: Whether the repository is private (boolean, optional) - `autoInit`: Auto-initialize with README (boolean, optional) +- **create_repository_using_template** - Create a new GitHub repository using a repository template + - `templateOwner`: Template repository owner (string, required) + - `templateName`: Template repository name (string, required) + - `name`: Repository name (string, required) + - `description`: Repository description (string, optional) + - `private`: Whether the repository is private (boolean, optional) + - `includeAllBranches`: Should include all branches from the template repository (boolean, optional) + - **get_file_contents** - Get contents of a file or directory - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4403e2a1..b37e52fd 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -408,6 +408,96 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun } } +// CreateRepositoryUsingTemplate creates a tool to create a new GitHub repository using a repository template. +func CreateRepositoryUsingTemplate(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_repository_using_template", + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_USING_TEMPLATE_DESCRIPTION", "Create a new GitHub repository in your account using a repository template")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_CREATE_REPOSITORY_USING_TEMPLATE_USER_TITLE", "Create repository using template"), + ReadOnlyHint: toBoolPtr(false), + }), + mcp.WithString("templateOwner", + mcp.Required(), + mcp.Description("Template repository owner (username or organization)"), + ), + mcp.WithString("templateName", + mcp.Required(), + mcp.Description("Template repository name"), + ), + mcp.WithString("name", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithString("description", + mcp.Description("Repository description"), + ), + mcp.WithBoolean("private", + mcp.Description("Whether repo should be private"), + ), + mcp.WithBoolean("includeAllBranches", + mcp.Description("Should include all branches from the template repository"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + templateOwner, err := requiredParam[string](request, "templateOwner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + templateName, err := requiredParam[string](request, "templateName") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + name, err := requiredParam[string](request, "name") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + description, err := OptionalParam[string](request, "description") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + private, err := OptionalParam[bool](request, "private") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + includeAllBranches, err := OptionalParam[bool](request, "includeAllBranches") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + repo := &github.TemplateRepoRequest{ + Name: github.Ptr(name), + Description: github.Ptr(description), + Private: github.Ptr(private), + IncludeAllBranches: github.Ptr(includeAllBranches), + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + createdRepo, resp, err := client.Repositories.CreateFromTemplate(ctx, templateOwner, templateName, repo) + if err != nil { + return nil, fmt.Errorf("failed to create repository using template: %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 repository using template: %s", string(body))), nil + } + + r, err := json.Marshal(createdRepo) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("get_file_contents", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e4edeee8..30a767b0 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1072,6 +1072,162 @@ func Test_CreateRepository(t *testing.T) { } } +func Test_CreateRepositoryUsingTemplate(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreateRepositoryUsingTemplate(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "create_repository_using_template", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "templateOwner") + assert.Contains(t, tool.InputSchema.Properties, "templateName") + assert.Contains(t, tool.InputSchema.Properties, "name") + assert.Contains(t, tool.InputSchema.Properties, "description") + assert.Contains(t, tool.InputSchema.Properties, "private") + assert.Contains(t, tool.InputSchema.Properties, "includeAllBranches") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"templateOwner", "templateName", "name"}) + + // Setup mock repository response + mockRepo := &github.Repository{ + Name: github.Ptr("test-repo"), + Description: github.Ptr("Test repository"), + Private: github.Ptr(true), + HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), + CloneURL: github.Ptr("https://github.com/testuser/test-repo.git"), + CreatedAt: &github.Timestamp{Time: time.Now()}, + Owner: &github.User{ + Login: github.Ptr("testuser"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedRepo *github.Repository + expectedErrMsg string + }{ + { + name: "successful repository creation with all parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/{template_owner}/{template_repo}/generate", + Method: "POST", + }, + expectPath(t, "/repos/test-repo-template-org/test-repo-template/generate").andThen( + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "Test repository", + "private": true, + "include_all_branches": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + )), + ), + ), + requestArgs: map[string]interface{}{ + "templateOwner": "test-repo-template-org", + "templateName": "test-repo-template", + "name": "test-repo", + "description": "Test repository", + "private": true, + "includeAllBranches": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "successful repository creation with minimal parameters", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/{template_owner}/{template_repo}/generate", + Method: "POST", + }, + expectPath(t, "/repos/test-repo-template-org/test-repo-template/generate").andThen( + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "include_all_branches": false, + "description": "", + "private": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + )), + ), + ), + requestArgs: map[string]interface{}{ + "templateOwner": "test-repo-template-org", + "templateName": "test-repo-template", + "name": "test-repo", + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "repository creation fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/{template_owner}/{template_repo}/generate", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "templateOwner": "test-repo-template-org", + "templateName": "test-repo-template", + "name": "invalid-repo", + }, + expectError: true, + expectedErrMsg: "failed to create repository", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateRepositoryUsingTemplate(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 + } + + require.NoError(t, err) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedRepo github.Repository + err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) + assert.NoError(t, err) + + // Verify repository details + assert.Equal(t, *tc.expectedRepo.Name, *returnedRepo.Name) + assert.Equal(t, *tc.expectedRepo.Description, *returnedRepo.Description) + assert.Equal(t, *tc.expectedRepo.Private, *returnedRepo.Private) + assert.Equal(t, *tc.expectedRepo.HTMLURL, *returnedRepo.HTMLURL) + assert.Equal(t, *tc.expectedRepo.Owner.Login, *returnedRepo.Owner.Login) + }) + } +} + func Test_PushFiles(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) @@ -1207,9 +1363,9 @@ func Test_PushFiles(t *testing.T) { expectedRef: mockUpdatedRef, }, { - name: "fails when files parameter is invalid", + name: "fails when files parameter is invalid", mockedClient: mock.NewMockedHTTPClient( - // No requests expected + // No requests expected ), requestArgs: map[string]interface{}{ "owner": "owner",