diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 7c1bc23e..085b184d 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -337,7 +337,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_repository", - mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account")), + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account without using a template")), mcp.WithToolAnnotation(mcp.ToolAnnotation{ Title: t("TOOL_CREATE_REPOSITORY_USER_TITLE", "Create repository"), ReadOnlyHint: false, @@ -408,6 +408,92 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun } } +// CreateRepositoryFromTemplate creates a tool to create a new GitHub repository from a template. +func CreateRepositoryFromTemplate(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("create_repository_from_template", + mcp.WithDescription(t("TOOL_CREATE_REPOSITORY_FROM_TEMPLATE_DESCRIPTION", "Create a new GitHub repository in your account from a template repository")), + 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("Include all branches from template"), + ), + mcp.WithString("templateOwner", + mcp.Required(), + mcp.Description("Template repository owner"), + ), + mcp.WithString("templateRepo", + mcp.Required(), + mcp.Description("Template repository name"), + ), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + 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 + } + templateOwner, err := requiredParam[string](request, "templateOwner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + templateRepo, err := requiredParam[string](request, "templateRepo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + templateReq := &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, templateRepo, templateReq) + if err != nil { + return nil, fmt.Errorf("failed to create repository from 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 from 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 5b8129fe..f86e435c 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1528,3 +1528,157 @@ func Test_ListBranches(t *testing.T) { }) } } + +func Test_CreateRepositoryFromTemplate(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := CreateRepositoryFromTemplate(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "create_repository_from_template", tool.Name) + assert.NotEmpty(t, tool.Description) + 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.Contains(t, tool.InputSchema.Properties, "templateOwner") + assert.Contains(t, tool.InputSchema.Properties, "templateRepo") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"name", "templateOwner", "templateRepo"}) + + // 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 from template with all params", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/template-owner/template-repo/generate", + Method: "POST", + }, + 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{}{ + "name": "test-repo", + "description": "Test repository", + "private": true, + "includeAllBranches": true, + "templateOwner": "template-owner", + "templateRepo": "template-repo", + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "successful repository creation from template with minimal params", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/template-owner/template-repo/generate", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "test-repo", + "description": "", + "private": false, + "include_all_branches": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "name": "test-repo", + "templateOwner": "template-owner", + "templateRepo": "template-repo", + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "repository creation from template 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 from template failed"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "name": "invalid-repo", + "templateOwner": "template-owner", + "templateRepo": "template-repo", + }, + expectError: true, + expectedErrMsg: "failed to create repository from template", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := CreateRepositoryFromTemplate(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) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d..d94a5a31 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -31,6 +31,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), toolsets.NewServerTool(CreateRepository(getClient, t)), + toolsets.NewServerTool(CreateRepositoryFromTemplate(getClient, t)), toolsets.NewServerTool(ForkRepository(getClient, t)), toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)),