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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,14 @@ The following sets of tools are available:
- `query`: Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more. (string, required)
- `sort`: Sort field ('indexed' only) (string, optional)

- **search_commits** - Search commits
- **Required OAuth Scopes**: `repo`
- `order`: Sort order (string, optional)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `query`: Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:>2024-01-01'. Supports advanced search syntax. (string, required)
- `sort`: Sort field ('author-date' or 'committer-date') (string, optional)

- **search_repositories** - Search repositories
- **Required OAuth Scopes**: `repo`
- `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional)
Expand Down
47 changes: 47 additions & 0 deletions pkg/github/__toolsnaps__/search_commits.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Search commits"
},
"description": "Search for commits across GitHub repositories using specialized commit search syntax. Great for finding specific changes, authors, or messages.",
"inputSchema": {
"properties": {
"order": {
"description": "Sort order",
"enum": [
"asc",
"desc"
],
"type": "string"
},
"page": {
"description": "Page number for pagination (min 1)",
"minimum": 1,
"type": "number"
},
"perPage": {
"description": "Results per page for pagination (min 1, max 100)",
"maximum": 100,
"minimum": 1,
"type": "number"
},
"query": {
"description": "Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:\u003e2024-01-01'. Supports advanced search syntax.",
"type": "string"
},
"sort": {
"description": "Sort field ('author-date' or 'committer-date')",
"enum": [
"author-date",
"committer-date"
],
"type": "string"
}
},
"required": [
"query"
],
"type": "object"
},
"name": "search_commits"
}
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const (
GetSearchIssues = "GET /search/issues"
GetSearchUsers = "GET /search/users"
GetSearchRepositories = "GET /search/repositories"
GetSearchCommits = "GET /search/commits"

// Raw content endpoints (used for GitHub raw content API, not standard API)
// These are used with the raw content client that interacts with raw.githubusercontent.com
Expand Down
7 changes: 7 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,13 @@ type MinimalIssueComment struct {
UpdatedAt string `json:"updated_at,omitempty"`
}

// MinimalSearchCommitsResult is the trimmed output type for commit search results.
type MinimalSearchCommitsResult struct {
TotalCount int `json:"total_count"`
IncompleteResults bool `json:"incomplete_results"`
Items []MinimalCommit `json:"items"`
}

// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
type MinimalFileContentResponse struct {
Content *MinimalFileContent `json:"content,omitempty"`
Expand Down
160 changes: 160 additions & 0 deletions pkg/github/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"time"

ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/inventory"
Expand Down Expand Up @@ -430,3 +431,162 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
},
)
}

// SearchCommits creates a tool to search for commits across GitHub repositories.
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"query": {
Type: "string",
Description: "Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:>2024-01-01'. Supports advanced search syntax.",
},
"sort": {
Type: "string",
Description: "Sort field ('author-date' or 'committer-date')",
Enum: []any{"author-date", "committer-date"},
},
"order": {
Type: "string",
Description: "Sort order",
Enum: []any{"asc", "desc"},
},
},
Required: []string{"query"},
}
WithPagination(schema)

return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "search_commits",
Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using specialized commit search syntax. Great for finding specific changes, authors, or messages."),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
ReadOnlyHint: true,
},
InputSchema: schema,
},
[]scopes.Scope{scopes.Repo},
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
query, err := RequiredParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
sort, err := OptionalParam[string](args, "sort")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
order, err := OptionalParam[string](args, "order")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
pagination, err := OptionalPaginationParams(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

opts := &github.SearchOptions{
Sort: sort,
Order: order,
ListOptions: github.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
},
}

client, err := deps.GetClient(ctx)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
}
result, resp, err := client.Search.Commits(ctx, query, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to search commits with query '%s'", query),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil
}

convertCommitResultToMinimalCommit := func(commit *github.CommitResult) MinimalCommit {
minimalCommit := MinimalCommit{
SHA: commit.GetSHA(),
HTMLURL: commit.GetHTMLURL(),
}

if commit.Commit != nil {
minimalCommit.Commit = &MinimalCommitInfo{
Message: commit.Commit.GetMessage(),
}

if commit.Commit.Author != nil {
minimalCommit.Commit.Author = &MinimalCommitAuthor{
Name: commit.Commit.Author.GetName(),
Email: commit.Commit.Author.GetEmail(),
}
if commit.Commit.Author.Date != nil {
minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339)
}
}

if commit.Commit.Committer != nil {
minimalCommit.Commit.Committer = &MinimalCommitAuthor{
Name: commit.Commit.Committer.GetName(),
Email: commit.Commit.Committer.GetEmail(),
}
if commit.Commit.Committer.Date != nil {
minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339)
}
}
}

if commit.Author != nil {
minimalCommit.Author = &MinimalUser{
Login: commit.Author.GetLogin(),
ID: commit.Author.GetID(),
ProfileURL: commit.Author.GetHTMLURL(),
AvatarURL: commit.Author.GetAvatarURL(),
}
}

if commit.Committer != nil {
minimalCommit.Committer = &MinimalUser{
Login: commit.Committer.GetLogin(),
ID: commit.Committer.GetID(),
ProfileURL: commit.Committer.GetHTMLURL(),
AvatarURL: commit.Committer.GetAvatarURL(),
}
}

return minimalCommit
}

minimalCommits := make([]MinimalCommit, 0, len(result.Commits))
for _, commit := range result.Commits {
minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit))
}

minimalResult := &MinimalSearchCommitsResult{
TotalCount: result.GetTotal(),
IncompleteResults: result.GetIncompleteResults(),
Items: minimalCommits,
}

r, err := json.Marshal(minimalResult)
if err != nil {
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
}

return utils.NewToolResultText(string(r)), nil, nil
},
)
}
126 changes: 126 additions & 0 deletions pkg/github/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"testing"
"time"

"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
Expand Down Expand Up @@ -725,3 +726,128 @@ func Test_SearchOrgs(t *testing.T) {
})
}
}

func Test_SearchCommits(t *testing.T) {
serverTool := SearchCommits(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "search_commits", tool.Name)
assert.NotEmpty(t, tool.Description)

schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Contains(t, schema.Properties, "query")
assert.Contains(t, schema.Properties, "sort")
assert.Contains(t, schema.Properties, "order")
assert.Contains(t, schema.Properties, "page")
assert.Contains(t, schema.Properties, "perPage")
assert.ElementsMatch(t, schema.Required, []string{"query"})

now := time.Now().Truncate(time.Second)
mockSearchResult := &github.CommitsSearchResult{
Total: github.Ptr(1),
IncompleteResults: github.Ptr(false),
Commits: []*github.CommitResult{
{
SHA: github.Ptr("abc123commit"),
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"),
Commit: &github.Commit{
Message: github.Ptr("Initial commit"),
Author: &github.CommitAuthor{
Name: github.Ptr("Author Name"),
Email: github.Ptr("author@example.com"),
Date: &github.Timestamp{Time: now},
},
},
Author: &github.User{
Login: github.Ptr("author"),
ID: github.Ptr(int64(1)),
HTMLURL: github.Ptr("https://github.com/author"),
},
},
},
}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedResult *github.CommitsSearchResult
expectedErrMsg string
}{
{
name: "successful commit search",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: expectQueryParams(t, map[string]string{
"q": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
"page": "1",
"per_page": "30",
}).andThen(
mockResponse(t, http.StatusOK, mockSearchResult),
),
}),
requestArgs: map[string]any{
"query": "fix bug in:message repo:owner/repo",
"sort": "author-date",
"order": "desc",
},
expectError: false,
expectedResult: mockSearchResult,
},
{
name: "search fails",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
}),
}),
requestArgs: map[string]any{
"query": "invalid:syntax",
},
expectError: true,
expectedErrMsg: "failed to search commits",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(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)
var returnedResult MinimalSearchCommitsResult
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
require.NoError(t, err)

assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount)
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits))
assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message)
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name)
assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date)
assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login)
})
}
}
Loading
Loading