diff --git a/README.md b/README.md index 3ca44428..f85663ab 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f52c0341..56500eaf 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -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", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 2dc0cff9..20f96dde 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -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) diff --git a/pkg/github/server.go b/pkg/github/server.go index 9dee1596..2d252b29 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -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))