Skip to content

Commit 6a97273

Browse files
add SearchCommits tool
1 parent 95726ad commit 6a97273

File tree

5 files changed

+291
-0
lines changed

5 files changed

+291
-0
lines changed

pkg/github/helper_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const (
138138
GetSearchIssues = "GET /search/issues"
139139
GetSearchUsers = "GET /search/users"
140140
GetSearchRepositories = "GET /search/repositories"
141+
GetSearchCommits = "GET /search/commits"
141142

142143
// Raw content endpoints (used for GitHub raw content API, not standard API)
143144
// These are used with the raw content client that interacts with raw.githubusercontent.com

pkg/github/minimal_types.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,13 @@ type MinimalIssueComment struct {
212212
UpdatedAt string `json:"updated_at,omitempty"`
213213
}
214214

215+
// MinimalSearchCommitsResult is the trimmed output type for commit search results.
216+
type MinimalSearchCommitsResult struct {
217+
TotalCount int `json:"total_count"`
218+
IncompleteResults bool `json:"incomplete_results"`
219+
Items []MinimalCommit `json:"items"`
220+
}
221+
215222
// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
216223
type MinimalFileContentResponse struct {
217224
Content *MinimalFileContent `json:"content,omitempty"`

pkg/github/search.go

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"time"
910

1011
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1112
"github.com/github/github-mcp-server/pkg/inventory"
@@ -430,3 +431,158 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
430431
},
431432
)
432433
}
434+
435+
// SearchCommits creates a tool to search for commits across GitHub repositories.
436+
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
437+
schema := &jsonschema.Schema{
438+
Type: "object",
439+
Properties: map[string]*jsonschema.Schema{
440+
"query": {
441+
Type: "string",
442+
Description: "Commit search query. Examples: 'repo:owner/repo fix bug', 'author:defunkt', 'committer-date:>2024-01-01'. Supports advanced search syntax.",
443+
},
444+
"sort": {
445+
Type: "string",
446+
Description: "Sort field ('author-date' or 'committer-date')",
447+
Enum: []any{"author-date", "committer-date"},
448+
},
449+
"order": {
450+
Type: "string",
451+
Description: "Sort order",
452+
Enum: []any{"asc", "desc"},
453+
},
454+
},
455+
Required: []string{"query"},
456+
}
457+
WithPagination(schema)
458+
459+
return NewTool(
460+
ToolsetMetadataRepos,
461+
mcp.Tool{
462+
Name: "search_commits",
463+
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."),
464+
Annotations: &mcp.ToolAnnotations{
465+
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
466+
ReadOnlyHint: true,
467+
},
468+
InputSchema: schema,
469+
},
470+
[]scopes.Scope{scopes.Repo},
471+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
472+
query, err := RequiredParam[string](args, "query")
473+
if err != nil {
474+
return utils.NewToolResultError(err.Error()), nil, nil
475+
}
476+
sort, err := OptionalParam[string](args, "sort")
477+
if err != nil {
478+
return utils.NewToolResultError(err.Error()), nil, nil
479+
}
480+
order, err := OptionalParam[string](args, "order")
481+
if err != nil {
482+
return utils.NewToolResultError(err.Error()), nil, nil
483+
}
484+
pagination, err := OptionalPaginationParams(args)
485+
if err != nil {
486+
return utils.NewToolResultError(err.Error()), nil, nil
487+
}
488+
489+
opts := &github.SearchOptions{
490+
Sort: sort,
491+
Order: order,
492+
ListOptions: github.ListOptions{
493+
Page: pagination.Page,
494+
PerPage: pagination.PerPage,
495+
},
496+
}
497+
498+
client, err := deps.GetClient(ctx)
499+
if err != nil {
500+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
501+
}
502+
result, resp, err := client.Search.Commits(ctx, query, opts)
503+
if err != nil {
504+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
505+
fmt.Sprintf("failed to search commits with query '%s'", query),
506+
resp,
507+
err,
508+
), nil, nil
509+
}
510+
defer func() { _ = resp.Body.Close() }()
511+
512+
if resp.StatusCode != http.StatusOK {
513+
body, err := io.ReadAll(resp.Body)
514+
if err != nil {
515+
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
516+
}
517+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil
518+
}
519+
520+
minimalCommits := make([]MinimalCommit, 0, len(result.Commits))
521+
for _, commit := range result.Commits {
522+
minimalCommit := MinimalCommit{
523+
SHA: commit.GetSHA(),
524+
HTMLURL: commit.GetHTMLURL(),
525+
}
526+
527+
if commit.Commit != nil {
528+
minimalCommit.Commit = &MinimalCommitInfo{
529+
Message: commit.Commit.GetMessage(),
530+
}
531+
532+
if commit.Commit.Author != nil {
533+
minimalCommit.Commit.Author = &MinimalCommitAuthor{
534+
Name: commit.Commit.Author.GetName(),
535+
Email: commit.Commit.Author.GetEmail(),
536+
}
537+
if commit.Commit.Author.Date != nil {
538+
minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339)
539+
}
540+
}
541+
542+
if commit.Commit.Committer != nil {
543+
minimalCommit.Commit.Committer = &MinimalCommitAuthor{
544+
Name: commit.Commit.Committer.GetName(),
545+
Email: commit.Commit.Committer.GetEmail(),
546+
}
547+
if commit.Commit.Committer.Date != nil {
548+
minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339)
549+
}
550+
}
551+
}
552+
553+
if commit.Author != nil {
554+
minimalCommit.Author = &MinimalUser{
555+
Login: commit.Author.GetLogin(),
556+
ID: commit.Author.GetID(),
557+
ProfileURL: commit.Author.GetHTMLURL(),
558+
AvatarURL: commit.Author.GetAvatarURL(),
559+
}
560+
}
561+
562+
if commit.Committer != nil {
563+
minimalCommit.Committer = &MinimalUser{
564+
Login: commit.Committer.GetLogin(),
565+
ID: commit.Committer.GetID(),
566+
ProfileURL: commit.Committer.GetHTMLURL(),
567+
AvatarURL: commit.Committer.GetAvatarURL(),
568+
}
569+
}
570+
571+
minimalCommits = append(minimalCommits, minimalCommit)
572+
}
573+
574+
minimalResult := &MinimalSearchCommitsResult{
575+
TotalCount: result.GetTotal(),
576+
IncompleteResults: result.GetIncompleteResults(),
577+
Items: minimalCommits,
578+
}
579+
580+
r, err := json.Marshal(minimalResult)
581+
if err != nil {
582+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
583+
}
584+
585+
return utils.NewToolResultText(string(r)), nil, nil
586+
},
587+
)
588+
}

pkg/github/search_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"net/http"
77
"testing"
8+
"time"
89

910
"github.com/github/github-mcp-server/internal/toolsnaps"
1011
"github.com/github/github-mcp-server/pkg/translations"
@@ -725,3 +726,128 @@ func Test_SearchOrgs(t *testing.T) {
725726
})
726727
}
727728
}
729+
730+
func Test_SearchCommits(t *testing.T) {
731+
serverTool := SearchCommits(translations.NullTranslationHelper)
732+
tool := serverTool.Tool
733+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
734+
735+
assert.Equal(t, "search_commits", tool.Name)
736+
assert.NotEmpty(t, tool.Description)
737+
738+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
739+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
740+
assert.Contains(t, schema.Properties, "query")
741+
assert.Contains(t, schema.Properties, "sort")
742+
assert.Contains(t, schema.Properties, "order")
743+
assert.Contains(t, schema.Properties, "page")
744+
assert.Contains(t, schema.Properties, "perPage")
745+
assert.ElementsMatch(t, schema.Required, []string{"query"})
746+
747+
now := time.Now().Truncate(time.Second)
748+
mockSearchResult := &github.CommitsSearchResult{
749+
Total: github.Ptr(1),
750+
IncompleteResults: github.Ptr(false),
751+
Commits: []*github.CommitResult{
752+
{
753+
SHA: github.Ptr("abc123commit"),
754+
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"),
755+
Commit: &github.Commit{
756+
Message: github.Ptr("Initial commit"),
757+
Author: &github.CommitAuthor{
758+
Name: github.Ptr("Author Name"),
759+
Email: github.Ptr("author@example.com"),
760+
Date: &github.Timestamp{Time: now},
761+
},
762+
},
763+
Author: &github.User{
764+
Login: github.Ptr("author"),
765+
ID: github.Ptr(int64(1)),
766+
HTMLURL: github.Ptr("https://github.com/author"),
767+
},
768+
},
769+
},
770+
}
771+
772+
tests := []struct {
773+
name string
774+
mockedClient *http.Client
775+
requestArgs map[string]any
776+
expectError bool
777+
expectedResult *github.CommitsSearchResult
778+
expectedErrMsg string
779+
}{
780+
{
781+
name: "successful commit search",
782+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
783+
GetSearchCommits: expectQueryParams(t, map[string]string{
784+
"q": "fix bug in:message repo:owner/repo",
785+
"sort": "author-date",
786+
"order": "desc",
787+
"page": "1",
788+
"per_page": "30",
789+
}).andThen(
790+
mockResponse(t, http.StatusOK, mockSearchResult),
791+
),
792+
}),
793+
requestArgs: map[string]any{
794+
"query": "fix bug in:message repo:owner/repo",
795+
"sort": "author-date",
796+
"order": "desc",
797+
},
798+
expectError: false,
799+
expectedResult: mockSearchResult,
800+
},
801+
{
802+
name: "search fails",
803+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
804+
GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
805+
w.WriteHeader(http.StatusUnprocessableEntity)
806+
_, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
807+
}),
808+
}),
809+
requestArgs: map[string]any{
810+
"query": "invalid:syntax",
811+
},
812+
expectError: true,
813+
expectedErrMsg: "failed to search commits",
814+
},
815+
}
816+
817+
for _, tc := range tests {
818+
t.Run(tc.name, func(t *testing.T) {
819+
client := github.NewClient(tc.mockedClient)
820+
deps := BaseDeps{
821+
Client: client,
822+
}
823+
handler := serverTool.Handler(deps)
824+
request := createMCPRequest(tc.requestArgs)
825+
826+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
827+
828+
if tc.expectError {
829+
require.NoError(t, err)
830+
require.True(t, result.IsError)
831+
errorContent := getErrorResult(t, result)
832+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
833+
return
834+
}
835+
836+
require.NoError(t, err)
837+
require.False(t, result.IsError)
838+
839+
textContent := getTextResult(t, result)
840+
var returnedResult MinimalSearchCommitsResult
841+
err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
842+
require.NoError(t, err)
843+
844+
assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount)
845+
assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits))
846+
assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA)
847+
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message)
848+
assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name)
849+
assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date)
850+
assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login)
851+
})
852+
}
853+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
169169
GetFileContents(t),
170170
ListCommits(t),
171171
SearchCode(t),
172+
SearchCommits(t),
172173
GetCommit(t),
173174
ListBranches(t),
174175
ListTags(t),

0 commit comments

Comments
 (0)