Skip to content

Add ability to view branches for a repo #141 #205

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Apr 11, 2025
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `branch`: Branch name (string, optional)
- `sha`: File SHA if updating (string, optional)

- **list_branches** - List branches in a GitHub repository

- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `page`: Page number (number, optional)
- `perPage`: Results per page (number, optional)

- **push_files** - Push multiple files in a single commit

- `owner`: Repository owner (string, required)
Expand Down
63 changes: 63 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,69 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
}

// ListBranches creates a tool to list branches in a GitHub repository.
func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_branches",
mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

opts := &github.BranchListOptions{
ListOptions: github.ListOptions{
Page: pagination.page,
PerPage: pagination.perPage,
},
}

client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
if err != nil {
return nil, fmt.Errorf("failed to list branches: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
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 list branches: %s", string(body))), nil
}

r, err := json.Marshal(branches)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_or_update_file",
Expand Down
110 changes: 110 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1423,3 +1423,113 @@ func Test_PushFiles(t *testing.T) {
})
}
}

func Test_ListBranches(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)

assert.Equal(t, "list_branches", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})

// Setup mock branches for success case
mockBranches := []*github.Branch{
{
Name: github.Ptr("main"),
Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")},
},
{
Name: github.Ptr("develop"),
Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")},
},
}

// Test cases
tests := []struct {
name string
args map[string]interface{}
mockResponses []mock.MockBackendOption
wantErr bool
errContains string
}{
{
name: "success",
args: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"page": float64(2),
},
mockResponses: []mock.MockBackendOption{
mock.WithRequestMatch(
mock.GetReposBranchesByOwnerByRepo,
mockBranches,
),
},
wantErr: false,
},
{
name: "missing owner",
args: map[string]interface{}{
"repo": "repo",
},
mockResponses: []mock.MockBackendOption{},
wantErr: false,
errContains: "missing required parameter: owner",
},
{
name: "missing repo",
args: map[string]interface{}{
"owner": "owner",
},
mockResponses: []mock.MockBackendOption{},
wantErr: false,
errContains: "missing required parameter: repo",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create mock client
mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...))
_, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)

// Create request
request := createMCPRequest(tt.args)

// Call handler
result, err := handler(context.Background(), request)
if tt.wantErr {
require.Error(t, err)
if tt.errContains != "" {
assert.Contains(t, err.Error(), tt.errContains)
}
return
}

require.NoError(t, err)
require.NotNil(t, result)

if tt.errContains != "" {
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, tt.errContains)
return
}

textContent := getTextResult(t, result)
require.NotEmpty(t, textContent.Text)

// Verify response
var branches []*github.Branch
err = json.Unmarshal([]byte(textContent.Text), &branches)
require.NoError(t, err)
assert.Len(t, branches, 2)
assert.Equal(t, "main", *branches[0].Name)
assert.Equal(t, "develop", *branches[1].Name)
})
}
}
1 change: 1 addition & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati
s.AddTool(GetFileContents(getClient, t))
s.AddTool(GetCommit(getClient, t))
s.AddTool(ListCommits(getClient, t))
s.AddTool(ListBranches(getClient, t))
if !readOnly {
s.AddTool(CreateOrUpdateFile(getClient, t))
s.AddTool(CreateRepository(getClient, t))
Expand Down