diff --git a/README.md b/README.md index 973b926d66..d913ae1552 100644 --- a/README.md +++ b/README.md @@ -1265,6 +1265,12 @@ 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 the repository should be private. Defaults to true (private) when omitted. (boolean, optional) +- **delete_branch** - Delete branch + - **Required OAuth Scopes**: `repo` + - `branch`: Name of the branch to delete (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - **delete_file** - Delete file - **Required OAuth Scopes**: `repo` - `branch`: Branch to delete the file from (string, required) diff --git a/pkg/github/__toolsnaps__/delete_branch.snap b/pkg/github/__toolsnaps__/delete_branch.snap new file mode 100644 index 0000000000..a6f729dc53 --- /dev/null +++ b/pkg/github/__toolsnaps__/delete_branch.snap @@ -0,0 +1,29 @@ +{ + "annotations": { + "title": "Delete branch" + }, + "description": "Delete a branch from a GitHub repository. Protected branches cannot be deleted.", + "inputSchema": { + "properties": { + "branch": { + "description": "Name of the branch to delete", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "branch" + ], + "type": "object" + }, + "name": "delete_branch" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 80adb19b4a..27235ae3b4 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -30,24 +30,26 @@ const ( DeleteUserStarredByOwnerByRepo = "DELETE /user/starred/{owner}/{repo}" // Repository endpoints - GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}" - GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches" - GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags" - GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits" - GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}" - GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}" - PutReposContentsByOwnerByRepoByPath = "PUT /repos/{owner}/{repo}/contents/{path}" - PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks" - GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" - PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" - DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" - ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" + GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}" + GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches" + GetReposBranchesByOwnerByRepoByBranch = "GET /repos/{owner}/{repo}/branches/{branch}" + GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags" + GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits" + GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}" + GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}" + PutReposContentsByOwnerByRepoByPath = "PUT /repos/{owner}/{repo}/contents/{path}" + PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks" + GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" + PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" + DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" + ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}" PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}" + DeleteReposGitRefsByOwnerByRepoByRef = "DELETE /repos/{owner}/{repo}/git/refs/{ref:.*}" GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 949a180081..54d6ac0441 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1292,6 +1292,87 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { ) } +// DeleteBranch creates a tool to delete a branch in a GitHub repository. +func DeleteBranch(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "delete_branch", + Description: t("TOOL_DELETE_BRANCH_DESCRIPTION", "Delete a branch from a GitHub repository. Protected branches cannot be deleted."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DELETE_BRANCH_USER_TITLE", "Delete branch"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "branch": { + Type: "string", + Description: "Name of the branch to delete", + }, + }, + Required: []string{"owner", "repo", "branch"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + branch, err := RequiredParam[string](args, "branch") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Guardrail: refuse to delete protected branches. + branchInfo, resp, err := client.Repositories.GetBranch(ctx, owner, repo, branch, 1) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if branchInfo.GetProtected() { + return utils.NewToolResultError(fmt.Sprintf("branch %q is protected and cannot be deleted", branch)), nil, nil + } + + // Delete the branch reference. + resp, err = client.Git.DeleteRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to delete branch", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + return utils.NewToolResultText(fmt.Sprintf("Successfully deleted branch %q", branch)), nil, nil + }, + ) +} + // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e5531cc55b..b66bb54407 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1022,6 +1022,131 @@ func Test_CreateBranch(t *testing.T) { } } +func Test_DeleteBranch(t *testing.T) { + // Verify tool definition once + serverTool := DeleteBranch(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, "delete_branch", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "delete_branch must not be read-only") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "branch") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "branch"}) + + unprotectedBranch := &github.Branch{ + Name: github.Ptr("feature"), + Protected: github.Ptr(false), + } + protectedBranch := &github.Branch{ + Name: github.Ptr("main"), + Protected: github.Ptr(true), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedSuccess string + }{ + { + name: "successful branch deletion", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, unprotectedBranch), + DeleteReposGitRefsByOwnerByRepoByRef: mockResponse(t, http.StatusNoContent, nil), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "branch": "feature", + }, + expectError: false, + expectedSuccess: `Successfully deleted branch "feature"`, + }, + { + name: "refuses to delete protected branch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, protectedBranch), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "branch": "main", + }, + expectError: true, + expectedErrMsg: "protected and cannot be deleted", + }, + { + name: "branch not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposBranchesByOwnerByRepoByBranch: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Branch not found"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "branch": "nonexistent", + }, + expectError: true, + expectedErrMsg: "failed to get branch", + }, + { + name: "fail to delete reference", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, unprotectedBranch), + DeleteReposGitRefsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) + }, + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "branch": "feature", + }, + expectError: true, + expectedErrMsg: "failed to delete branch", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &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) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedSuccess) + }) + } +} + func Test_GetCommit(t *testing.T) { // Verify tool definition once serverTool := GetCommit(translations.NullTranslationHelper) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e361a6cfa4..e3cdb95f84 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -197,6 +197,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { CreateRepository(t), ForkRepository(t), CreateBranch(t), + DeleteBranch(t), PushFiles(t), DeleteFile(t), ListStarredRepositories(t),