Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions pkg/github/__toolsnaps__/delete_branch.snap
Original file line number Diff line number Diff line change
@@ -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"
}
26 changes: 14 additions & 12 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
81 changes: 81 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
125 changes: 125 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down