Skip to content

Commit cec491c

Browse files
committed
feat: Add list_branches tool to view repository branches (#141)
1 parent 6b02799 commit cec491c

File tree

3 files changed

+183
-0
lines changed

3 files changed

+183
-0
lines changed

pkg/github/repositories.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,70 @@ func pushFiles(client *github.Client, t translations.TranslationHelperFunc) (too
610610
return mcp.NewToolResultText(string(r)), nil
611611
}
612612
}
613+
614+
// listBranches creates a tool to list branches in a repository.
615+
func listBranches(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
616+
return mcp.NewTool("list_branches",
617+
mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")),
618+
mcp.WithString("owner",
619+
mcp.Required(),
620+
mcp.Description("Repository owner"),
621+
),
622+
mcp.WithString("repo",
623+
mcp.Required(),
624+
mcp.Description("Repository name"),
625+
),
626+
mcp.WithNumber("page",
627+
mcp.Description("Page number"),
628+
),
629+
mcp.WithNumber("perPage",
630+
mcp.Description("Results per page"),
631+
),
632+
),
633+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
634+
owner, err := requiredParam[string](request, "owner")
635+
if err != nil {
636+
return mcp.NewToolResultError(err.Error()), nil
637+
}
638+
repo, err := requiredParam[string](request, "repo")
639+
if err != nil {
640+
return mcp.NewToolResultError(err.Error()), nil
641+
}
642+
page, err := optionalIntParamWithDefault(request, "page", 1)
643+
if err != nil {
644+
return mcp.NewToolResultError(err.Error()), nil
645+
}
646+
perPage, err := optionalIntParamWithDefault(request, "per_page", 30)
647+
if err != nil {
648+
return mcp.NewToolResultError(err.Error()), nil
649+
}
650+
651+
opts := &github.BranchListOptions{
652+
ListOptions: github.ListOptions{
653+
Page: page,
654+
PerPage: perPage,
655+
},
656+
}
657+
658+
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
659+
if err != nil {
660+
return nil, fmt.Errorf("failed to list branches: %w", err)
661+
}
662+
defer func() { _ = resp.Body.Close() }()
663+
664+
if resp.StatusCode != http.StatusOK {
665+
body, err := io.ReadAll(resp.Body)
666+
if err != nil {
667+
return nil, fmt.Errorf("failed to read response body: %w", err)
668+
}
669+
return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil
670+
}
671+
672+
r, err := json.Marshal(branches)
673+
if err != nil {
674+
return nil, fmt.Errorf("failed to marshal response: %w", err)
675+
}
676+
677+
return mcp.NewToolResultText(string(r)), nil
678+
}
679+
}

pkg/github/repositories_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1293,3 +1293,118 @@ func Test_PushFiles(t *testing.T) {
12931293
})
12941294
}
12951295
}
1296+
1297+
func Test_ListBranches(t *testing.T) {
1298+
// Verify tool definition once
1299+
mockClient := github.NewClient(nil)
1300+
tool, _ := listBranches(mockClient, translations.NullTranslationHelper)
1301+
1302+
assert.Equal(t, "list_branches", tool.Name)
1303+
assert.NotEmpty(t, tool.Description)
1304+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1305+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1306+
assert.Contains(t, tool.InputSchema.Properties, "page")
1307+
assert.Contains(t, tool.InputSchema.Properties, "perPage")
1308+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
1309+
1310+
// Setup mock branches for success case
1311+
mockBranches := []*github.Branch{
1312+
{
1313+
Name: github.Ptr("main"),
1314+
Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")},
1315+
},
1316+
{
1317+
Name: github.Ptr("develop"),
1318+
Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")},
1319+
},
1320+
}
1321+
1322+
// Define test cases
1323+
tests := []struct {
1324+
name string
1325+
mockedClient *http.Client
1326+
requestArgs map[string]interface{}
1327+
expectError bool
1328+
expectedErrMsg string
1329+
}{
1330+
{
1331+
name: "success",
1332+
mockedClient: mock.NewMockedHTTPClient(
1333+
mock.WithRequestMatch(
1334+
mock.GetReposBranchesByOwnerByRepo,
1335+
mockBranches,
1336+
),
1337+
),
1338+
requestArgs: map[string]interface{}{
1339+
"owner": "owner",
1340+
"repo": "repo",
1341+
},
1342+
expectError: false,
1343+
},
1344+
{
1345+
name: "missing owner",
1346+
mockedClient: mock.NewMockedHTTPClient(),
1347+
requestArgs: map[string]interface{}{
1348+
"repo": "repo",
1349+
},
1350+
expectError: false,
1351+
expectedErrMsg: "owner is required",
1352+
},
1353+
{
1354+
name: "missing repo",
1355+
mockedClient: mock.NewMockedHTTPClient(),
1356+
requestArgs: map[string]interface{}{
1357+
"owner": "owner",
1358+
},
1359+
expectError: false,
1360+
expectedErrMsg: "repo is required",
1361+
},
1362+
{
1363+
name: "repository not found",
1364+
mockedClient: mock.NewMockedHTTPClient(
1365+
mock.WithRequestMatchHandler(
1366+
mock.GetReposBranchesByOwnerByRepo,
1367+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
1368+
w.WriteHeader(http.StatusNotFound)
1369+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
1370+
}),
1371+
),
1372+
),
1373+
requestArgs: map[string]interface{}{
1374+
"owner": "owner",
1375+
"repo": "nonexistent-repo",
1376+
},
1377+
expectError: true,
1378+
expectedErrMsg: "failed to list branches",
1379+
},
1380+
}
1381+
1382+
for _, tt := range tests {
1383+
t.Run(tt.name, func(t *testing.T) {
1384+
client := github.NewClient(tt.mockedClient)
1385+
_, handler := listBranches(client, translations.NullTranslationHelper)
1386+
1387+
// Create call request using helper function
1388+
request := createMCPRequest(tt.requestArgs)
1389+
1390+
// Call handler
1391+
result, err := handler(context.Background(), request)
1392+
1393+
if tt.expectError {
1394+
assert.Error(t, err)
1395+
assert.Contains(t, err.Error(), tt.expectedErrMsg)
1396+
} else {
1397+
if tt.expectedErrMsg != "" {
1398+
assert.NotNil(t, result)
1399+
textContent := getTextResult(t, result)
1400+
assert.Contains(t, textContent.Text, tt.expectedErrMsg)
1401+
} else {
1402+
assert.NoError(t, err)
1403+
assert.NotNil(t, result)
1404+
textContent := getTextResult(t, result)
1405+
assert.NotEmpty(t, textContent.Text)
1406+
}
1407+
}
1408+
})
1409+
}
1410+
}

pkg/github/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH
5959
s.AddTool(searchRepositories(client, t))
6060
s.AddTool(getFileContents(client, t))
6161
s.AddTool(listCommits(client, t))
62+
s.AddTool(listBranches(client, t))
6263
if !readOnly {
6364
s.AddTool(createOrUpdateFile(client, t))
6465
s.AddTool(createRepository(client, t))

0 commit comments

Comments
 (0)