Add tool for getting a single commit (includes stats, files) (#216)

* Add tool for getting a commit

* Split mock back out, use RepositoryCommit with Files/Stats
This commit is contained in:
Jake Shorty
2025-04-11 00:09:19 -06:00
committed by GitHub
parent 651a3aaac5
commit 919a10c187
4 changed files with 206 additions and 1 deletions
+8 -1
View File
@@ -354,7 +354,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `branch`: New branch name (string, required)
- `sha`: SHA to create branch from (string, required)
- **list_commits** - Gets commits of a branch in a repository
- **list_commits** - Get a list of commits of a branch in a repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `sha`: Branch name, tag, or commit SHA (string, optional)
@@ -362,6 +362,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `page`: Page number (number, optional)
- `perPage`: Results per page (number, optional)
- **get_commit** - Get details for a commit from a repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `sha`: Commit SHA, branch name, or tag name (string, required)
- `page`: Page number, for files in the commit (number, optional)
- `perPage`: Results per page, for files in the commit (number, optional)
### Search
- **search_code** - Search for code across GitHub repositories
+67
View File
@@ -13,6 +13,73 @@ import (
"github.com/mark3labs/mcp-go/server"
)
func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_commit",
mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithString("sha",
mcp.Required(),
mcp.Description("Commit SHA, branch name, or tag 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
}
sha, err := requiredParam[string](request, "sha")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &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)
}
commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts)
if err != nil {
return nil, fmt.Errorf("failed to get commit: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
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 get commit: %s", string(body))), nil
}
r, err := json.Marshal(commit)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// ListCommits creates a tool to get commits of a branch in a repository.
func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_commits",
+130
View File
@@ -475,6 +475,136 @@ func Test_CreateBranch(t *testing.T) {
}
}
func Test_GetCommit(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper)
assert.Equal(t, "get_commit", 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, "sha")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"})
mockCommit := &github.RepositoryCommit{
SHA: github.Ptr("abc123def456"),
Commit: &github.Commit{
Message: github.Ptr("First commit"),
Author: &github.CommitAuthor{
Name: github.Ptr("Test User"),
Email: github.Ptr("test@example.com"),
Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)},
},
},
Author: &github.User{
Login: github.Ptr("testuser"),
},
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
Stats: &github.CommitStats{
Additions: github.Ptr(10),
Deletions: github.Ptr(2),
Total: github.Ptr(12),
},
Files: []*github.CommitFile{
{
Filename: github.Ptr("file1.go"),
Status: github.Ptr("modified"),
Additions: github.Ptr(10),
Deletions: github.Ptr(2),
Changes: github.Ptr(12),
Patch: github.Ptr("@@ -1,2 +1,10 @@"),
},
},
}
// This one currently isn't defined in the mock package we're using.
var mockEndpointPattern = mock.EndpointPattern{
Pattern: "/repos/{owner}/{repo}/commits/{sha}",
Method: "GET",
}
tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]interface{}
expectError bool
expectedCommit *github.RepositoryCommit
expectedErrMsg string
}{
{
name: "successful commit fetch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mockEndpointPattern,
mockResponse(t, http.StatusOK, mockCommit),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"sha": "abc123def456",
},
expectError: false,
expectedCommit: mockCommit,
},
{
name: "commit fetch fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mockEndpointPattern,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
}),
),
),
requestArgs: map[string]interface{}{
"owner": "owner",
"repo": "repo",
"sha": "nonexistent-sha",
},
expectError: true,
expectedErrMsg: "failed to get commit",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
_, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
// Call handler
result, err := handler(context.Background(), request)
// Verify results
if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErrMsg)
return
}
require.NoError(t, err)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
// Unmarshal and verify the result
var returnedCommit github.RepositoryCommit
err = json.Unmarshal([]byte(textContent.Text), &returnedCommit)
require.NoError(t, err)
assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA)
assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message)
assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login)
assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL)
})
}
}
func Test_ListCommits(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
+1
View File
@@ -61,6 +61,7 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati
// Add GitHub tools - Repositories
s.AddTool(SearchRepositories(getClient, t))
s.AddTool(GetFileContents(getClient, t))
s.AddTool(GetCommit(getClient, t))
s.AddTool(ListCommits(getClient, t))
if !readOnly {
s.AddTool(CreateOrUpdateFile(getClient, t))