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
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
156 changes: 156 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,158 @@ 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
}

minimalCommits := make([]MinimalCommit, 0, len(result.Commits))
for _, commit := range result.Commits {
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(),
}
}

minimalCommits = append(minimalCommits, minimalCommit)
}

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)
})
}
}
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
GetFileContents(t),
ListCommits(t),
SearchCode(t),
SearchCommits(t),
GetCommit(t),
ListBranches(t),
ListTags(t),
Expand Down