diff --git a/README.md b/README.md index 059256aaa..0b763c8a0 100644 --- a/README.md +++ b/README.md @@ -1113,6 +1113,15 @@ The following sets of tools are available: - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - `private`: Whether repo should be private (boolean, optional) +- **create_repository_from_template** - Create repository from template + - `description`: Description for the new repository (string, optional) + - `include_all_branches`: Whether to include all branches from template (default: false, only default branch) (boolean, optional) + - `name`: Name for the new repository (string, required) + - `owner`: Owner for the new repository (username or organization). Omit to create in your personal account (string, optional) + - `private`: Whether the new repository should be private (boolean, optional) + - `template_owner`: Owner of the template repository (string, required) + - `template_repo`: Name of the template repository (string, required) + - **delete_file** - Delete file - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) diff --git a/pkg/github/__toolsnaps__/create_repository_from_template.snap b/pkg/github/__toolsnaps__/create_repository_from_template.snap new file mode 100644 index 000000000..ac07ac870 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_from_template.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "title": "Create repository from template" + }, + "description": "Create a new GitHub repository from a template repository", + "inputSchema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "Description for the new repository" + }, + "include_all_branches": { + "type": "boolean", + "description": "Whether to include all branches from template (default: false, only default branch)" + }, + "name": { + "type": "string", + "description": "Name for the new repository" + }, + "owner": { + "type": "string", + "description": "Owner for the new repository (username or organization). Omit to create in your personal account" + }, + "private": { + "type": "boolean", + "description": "Whether the new repository should be private" + }, + "template_owner": { + "type": "string", + "description": "Owner of the template repository" + }, + "template_repo": { + "type": "string", + "description": "Name of the template repository" + } + }, + "required": [ + "template_owner", + "template_repo", + "name" + ] + }, + "name": "create_repository_from_template" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 1b68b4222..79cf92baf 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -612,6 +612,134 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// CreateRepositoryFromTemplate creates a tool to create a new GitHub repository from a template. +func CreateRepositoryFromTemplate(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "create_repository_from_template", + Description: t("TOOL_CREATE_REPOSITORY_FROM_TEMPLATE_DESCRIPTION", "Create a new GitHub repository from a template repository"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_FROM_TEMPLATE_USER_TITLE", "Create repository from template"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "template_owner": { + Type: "string", + Description: "Owner of the template repository", + }, + "template_repo": { + Type: "string", + Description: "Name of the template repository", + }, + "name": { + Type: "string", + Description: "Name for the new repository", + }, + "description": { + Type: "string", + Description: "Description for the new repository", + }, + "owner": { + Type: "string", + Description: "Owner for the new repository (username or organization). Omit to create in your personal account", + }, + "private": { + Type: "boolean", + Description: "Whether the new repository should be private", + }, + "include_all_branches": { + Type: "boolean", + Description: "Whether to include all branches from template (default: false, only default branch)", + }, + }, + Required: []string{"template_owner", "template_repo", "name"}, + }, + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + templateOwner, err := RequiredParam[string](args, "template_owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + templateRepo, err := RequiredParam[string](args, "template_repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + name, err := RequiredParam[string](args, "name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + description, err := OptionalParam[string](args, "description") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + owner, err := OptionalParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + private, err := OptionalParam[bool](args, "private") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + includeAllBranches, err := OptionalParam[bool](args, "include_all_branches") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + templateReq := &github.TemplateRepoRequest{ + Name: github.Ptr(name), + Private: github.Ptr(private), + IncludeAllBranches: github.Ptr(includeAllBranches), + } + // Only set owner if provided (otherwise GitHub uses authenticated user) + if owner != "" { + templateReq.Owner = github.Ptr(owner) + } + // Only set description if provided (otherwise repository has no description) + if description != "" { + templateReq.Description = github.Ptr(description) + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + createdRepo, resp, err := client.Repositories.CreateFromTemplate(ctx, templateOwner, templateRepo, templateReq) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create repository from template", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create repository from template", resp, body), nil, nil + } + + // Return minimal response with just essential information + minimalResponse := MinimalResponse{ + ID: fmt.Sprintf("%d", createdRepo.GetID()), + URL: createdRepo.GetHTMLURL(), + } + + r, err := json.Marshal(minimalResponse) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 6c56d104e..1258adf95 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1593,6 +1593,210 @@ func Test_CreateRepository(t *testing.T) { } } +func Test_CreateRepositoryFromTemplate(t *testing.T) { + // Verify tool definition once + serverTool := CreateRepositoryFromTemplate(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "create_repository_from_template", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "template_owner") + assert.Contains(t, schema.Properties, "template_repo") + assert.Contains(t, schema.Properties, "name") + assert.Contains(t, schema.Properties, "description") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "private") + assert.Contains(t, schema.Properties, "include_all_branches") + assert.ElementsMatch(t, schema.Required, []string{"template_owner", "template_repo", "name"}) + + // Setup mock repository response + mockRepo := &github.Repository{ + ID: github.Ptr(int64(123456)), + Name: github.Ptr("new-repo"), + Description: github.Ptr("New repository from template"), + Private: github.Ptr(false), + HTMLURL: github.Ptr("https://github.com/testuser/new-repo"), + 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", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/template-owner/template-repo/generate", + Method: "POST", + }, + expectRequestBody(t, map[string]interface{}{ + "name": "new-repo", + "description": "New repository from template", + "owner": "testuser", + "private": false, + "include_all_branches": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]interface{}{ + "template_owner": "template-owner", + "template_repo": "template-repo", + "name": "new-repo", + "description": "New repository from template", + "owner": "testuser", + "private": false, + "include_all_branches": false, + }, + 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", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(mockRepo)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "template_owner": "template-owner", + "template_repo": "template-repo", + "name": "new-repo", + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "successful repository creation including all branches", + 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.StatusCreated) + _, _ = w.Write(mock.MustMarshal(mockRepo)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "template_owner": "template-owner", + "template_repo": "template-repo", + "name": "new-repo", + "include_all_branches": true, + }, + expectError: false, + expectedRepo: mockRepo, + }, + { + name: "repository creation fails - template not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{ + Pattern: "/repos/invalid-owner/invalid-repo/generate", + Method: "POST", + }, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "template_owner": "invalid-owner", + "template_repo": "invalid-repo", + "name": "new-repo", + }, + expectError: true, + expectedErrMsg: "failed to create repository from template", + }, + { + name: "repository creation fails - name already exists", + 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{}{ + "template_owner": "template-owner", + "template_repo": "template-repo", + "name": "existing-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) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + 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) + require.False(t, result.IsError) + + // Parse the result and get the text content if no error + textContent := getTextResult(t, result) + + // Unmarshal and verify the minimal result + var returnedRepo MinimalResponse + err = json.Unmarshal([]byte(textContent.Text), &returnedRepo) + assert.NoError(t, err) + + // Verify repository details + assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL) + }) + } +} + func Test_PushFiles(t *testing.T) { // Verify tool definition once serverTool := PushFiles(translations.NullTranslationHelper) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f6d4afa80..5b09e0579 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -170,6 +170,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetReleaseByTag(t), CreateOrUpdateFile(t), CreateRepository(t), + CreateRepositoryFromTemplate(t), ForkRepository(t), CreateBranch(t), PushFiles(t),