Skip to content

Commit 7099f23

Browse files
Not-Dhananjay-MishraCopilotSamMorrowDrumsCopilot
committed
feat: Add search commit tool (#2284)
* add `SearchCommits` tool * run test * run script/generate-docs * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(search_commits): share commit conversion, surface repo, tighten query docs - Extract newMinimalCommitFromCore to share field mapping between convertToMinimalCommit (RepositoryCommit) and the new convertCommitResultToMinimalCommit (CommitResult), removing ~50 lines of duplicated logic from the search_commits handler. - Add MinimalRepoRef and a search-only MinimalCommitSearchItem type (embedding MinimalCommit) so cross-repo commit search results identify the repo each commit came from. Keeping the field off MinimalCommit avoids paying for a never-populated field on the get_commit/list_commits output types. - Rewrite the query description to teach the model the actual commit-search qualifier surface (repo:/org:/user: scoping, author/ committer/date qualifiers, hash/tree/parent, merge:, is:public) and reword the sort description to drop redundancy with the enum. - Extend tests to assert the repository field is surfaced and to cover commits with no resolved GitHub user (nil Author/Committer). - Refresh README and toolsnap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow <info@sam-morrow.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dd5ed52 commit 7099f23

6 files changed

Lines changed: 406 additions & 26 deletions

File tree

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "Search commits"
5+
},
6+
"description": "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only.",
7+
"inputSchema": {
8+
"properties": {
9+
"order": {
10+
"description": "Sort order",
11+
"enum": [
12+
"asc",
13+
"desc"
14+
],
15+
"type": "string"
16+
},
17+
"page": {
18+
"description": "Page number for pagination (min 1)",
19+
"minimum": 1,
20+
"type": "number"
21+
},
22+
"perPage": {
23+
"description": "Results per page for pagination (min 1, max 100)",
24+
"maximum": 100,
25+
"minimum": 1,
26+
"type": "number"
27+
},
28+
"query": {
29+
"description": "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `\u003e`, `\u003c`, `\u003e=`, `\u003c=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:\u003e=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.",
30+
"type": "string"
31+
},
32+
"sort": {
33+
"description": "Sort by author or committer date (defaults to best match)",
34+
"enum": [
35+
"author-date",
36+
"committer-date"
37+
],
38+
"type": "string"
39+
}
40+
},
41+
"required": [
42+
"query"
43+
],
44+
"type": "object"
45+
},
46+
"name": "search_commits"
47+
}

pkg/github/helper_test.go

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

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

pkg/github/minimal_types.go

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ type MinimalCommit struct {
129129
Files []MinimalCommitFile `json:"files,omitempty"`
130130
}
131131

132+
// MinimalRepoRef is a lightweight reference to a repository, used when a
133+
// result needs to identify which repository it belongs to (for example, in
134+
// cross-repo commit search results).
135+
type MinimalRepoRef struct {
136+
FullName string `json:"full_name"`
137+
HTMLURL string `json:"html_url,omitempty"`
138+
Private bool `json:"private,omitempty"`
139+
}
140+
141+
// MinimalCommitSearchItem extends MinimalCommit with the containing
142+
// repository, since commit search spans repositories and callers need to
143+
// know which repo each result came from.
144+
type MinimalCommitSearchItem struct {
145+
MinimalCommit
146+
Repository *MinimalRepoRef `json:"repository,omitempty"`
147+
}
148+
132149
// MinimalRelease is the trimmed output type for release objects.
133150
type MinimalRelease struct {
134151
ID int64 `json:"id"`
@@ -244,6 +261,13 @@ type MinimalIssueComment struct {
244261
UpdatedAt string `json:"updated_at,omitempty"`
245262
}
246263

264+
// MinimalSearchCommitsResult is the trimmed output type for commit search results.
265+
type MinimalSearchCommitsResult struct {
266+
TotalCount int `json:"total_count"`
267+
IncompleteResults bool `json:"incomplete_results"`
268+
Items []MinimalCommitSearchItem `json:"items"`
269+
}
270+
247271
// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
248272
type MinimalFileContentResponse struct {
249273
Content *MinimalFileContent `json:"content,omitempty"`
@@ -649,57 +673,73 @@ func convertToMinimalUser(user *github.User) *MinimalUser {
649673
}
650674
}
651675

652-
// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
653-
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
676+
// newMinimalCommitFromCore builds a MinimalCommit from the fields that are
677+
// shared between *github.RepositoryCommit and *github.CommitResult. Caller
678+
// is responsible for setting any type-specific extras (stats/files for
679+
// RepositoryCommit, repository for CommitResult).
680+
func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author, committer *github.User) MinimalCommit {
654681
minimalCommit := MinimalCommit{
655-
SHA: commit.GetSHA(),
656-
HTMLURL: commit.GetHTMLURL(),
682+
SHA: sha,
683+
HTMLURL: htmlURL,
657684
}
658685

659-
if commit.Commit != nil {
686+
if commit != nil {
660687
minimalCommit.Commit = &MinimalCommitInfo{
661-
Message: commit.Commit.GetMessage(),
688+
Message: commit.GetMessage(),
662689
}
663690

664-
if commit.Commit.Author != nil {
691+
if commit.Author != nil {
665692
minimalCommit.Commit.Author = &MinimalCommitAuthor{
666-
Name: commit.Commit.Author.GetName(),
667-
Email: commit.Commit.Author.GetEmail(),
693+
Name: commit.Author.GetName(),
694+
Email: commit.Author.GetEmail(),
668695
}
669-
if commit.Commit.Author.Date != nil {
670-
minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339)
696+
if commit.Author.Date != nil {
697+
minimalCommit.Commit.Author.Date = commit.Author.Date.Format(time.RFC3339)
671698
}
672699
}
673700

674-
if commit.Commit.Committer != nil {
701+
if commit.Committer != nil {
675702
minimalCommit.Commit.Committer = &MinimalCommitAuthor{
676-
Name: commit.Commit.Committer.GetName(),
677-
Email: commit.Commit.Committer.GetEmail(),
703+
Name: commit.Committer.GetName(),
704+
Email: commit.Committer.GetEmail(),
678705
}
679-
if commit.Commit.Committer.Date != nil {
680-
minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339)
706+
if commit.Committer.Date != nil {
707+
minimalCommit.Commit.Committer.Date = commit.Committer.Date.Format(time.RFC3339)
681708
}
682709
}
683710
}
684711

685-
if commit.Author != nil {
712+
if author != nil {
686713
minimalCommit.Author = &MinimalUser{
687-
Login: commit.Author.GetLogin(),
688-
ID: commit.Author.GetID(),
689-
ProfileURL: commit.Author.GetHTMLURL(),
690-
AvatarURL: commit.Author.GetAvatarURL(),
714+
Login: author.GetLogin(),
715+
ID: author.GetID(),
716+
ProfileURL: author.GetHTMLURL(),
717+
AvatarURL: author.GetAvatarURL(),
691718
}
692719
}
693720

694-
if commit.Committer != nil {
721+
if committer != nil {
695722
minimalCommit.Committer = &MinimalUser{
696-
Login: commit.Committer.GetLogin(),
697-
ID: commit.Committer.GetID(),
698-
ProfileURL: commit.Committer.GetHTMLURL(),
699-
AvatarURL: commit.Committer.GetAvatarURL(),
723+
Login: committer.GetLogin(),
724+
ID: committer.GetID(),
725+
ProfileURL: committer.GetHTMLURL(),
726+
AvatarURL: committer.GetAvatarURL(),
700727
}
701728
}
702729

730+
return minimalCommit
731+
}
732+
733+
// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
734+
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
735+
minimalCommit := newMinimalCommitFromCore(
736+
commit.GetSHA(),
737+
commit.GetHTMLURL(),
738+
commit.Commit,
739+
commit.Author,
740+
commit.Committer,
741+
)
742+
703743
// Only include stats and files if includeDiffs is true
704744
if includeDiffs {
705745
if commit.Stats != nil {
@@ -728,6 +768,31 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool)
728768
return minimalCommit
729769
}
730770

771+
// convertCommitResultToMinimalCommit converts a GitHub API commit search
772+
// result, attaching the containing repository so the caller can tell which
773+
// repo each result came from.
774+
func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalCommitSearchItem {
775+
item := MinimalCommitSearchItem{
776+
MinimalCommit: newMinimalCommitFromCore(
777+
commit.GetSHA(),
778+
commit.GetHTMLURL(),
779+
commit.Commit,
780+
commit.Author,
781+
commit.Committer,
782+
),
783+
}
784+
785+
if commit.Repository != nil {
786+
item.Repository = &MinimalRepoRef{
787+
FullName: commit.Repository.GetFullName(),
788+
HTMLURL: commit.Repository.GetHTMLURL(),
789+
Private: commit.Repository.GetPrivate(),
790+
}
791+
}
792+
793+
return item
794+
}
795+
731796
// MinimalPageInfo contains pagination cursor information.
732797
type MinimalPageInfo struct {
733798
HasNextPage bool `json:"hasNextPage"`

pkg/github/search.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,109 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
430430
},
431431
)
432432
}
433+
434+
// SearchCommits creates a tool to search for commits across GitHub repositories.
435+
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
436+
schema := &jsonschema.Schema{
437+
Type: "object",
438+
Properties: map[string]*jsonschema.Schema{
439+
"query": {
440+
Type: "string",
441+
Description: "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.",
442+
},
443+
"sort": {
444+
Type: "string",
445+
Description: "Sort by author or committer date (defaults to best match)",
446+
Enum: []any{"author-date", "committer-date"},
447+
},
448+
"order": {
449+
Type: "string",
450+
Description: "Sort order",
451+
Enum: []any{"asc", "desc"},
452+
},
453+
},
454+
Required: []string{"query"},
455+
}
456+
WithPagination(schema)
457+
458+
return NewTool(
459+
ToolsetMetadataRepos,
460+
mcp.Tool{
461+
Name: "search_commits",
462+
Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."),
463+
Annotations: &mcp.ToolAnnotations{
464+
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
465+
ReadOnlyHint: true,
466+
},
467+
InputSchema: schema,
468+
},
469+
[]scopes.Scope{scopes.Repo},
470+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
471+
query, err := RequiredParam[string](args, "query")
472+
if err != nil {
473+
return utils.NewToolResultError(err.Error()), nil, nil
474+
}
475+
sort, err := OptionalParam[string](args, "sort")
476+
if err != nil {
477+
return utils.NewToolResultError(err.Error()), nil, nil
478+
}
479+
order, err := OptionalParam[string](args, "order")
480+
if err != nil {
481+
return utils.NewToolResultError(err.Error()), nil, nil
482+
}
483+
pagination, err := OptionalPaginationParams(args)
484+
if err != nil {
485+
return utils.NewToolResultError(err.Error()), nil, nil
486+
}
487+
488+
opts := &github.SearchOptions{
489+
Sort: sort,
490+
Order: order,
491+
ListOptions: github.ListOptions{
492+
Page: pagination.Page,
493+
PerPage: pagination.PerPage,
494+
},
495+
}
496+
497+
client, err := deps.GetClient(ctx)
498+
if err != nil {
499+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
500+
}
501+
result, resp, err := client.Search.Commits(ctx, query, opts)
502+
if err != nil {
503+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
504+
fmt.Sprintf("failed to search commits with query '%s'", query),
505+
resp,
506+
err,
507+
), nil, nil
508+
}
509+
defer func() { _ = resp.Body.Close() }()
510+
511+
if resp.StatusCode != http.StatusOK {
512+
body, err := io.ReadAll(resp.Body)
513+
if err != nil {
514+
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
515+
}
516+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil
517+
}
518+
519+
minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits))
520+
for _, commit := range result.Commits {
521+
minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit))
522+
}
523+
524+
minimalResult := &MinimalSearchCommitsResult{
525+
TotalCount: result.GetTotal(),
526+
IncompleteResults: result.GetIncompleteResults(),
527+
Items: minimalCommits,
528+
}
529+
530+
r, err := json.Marshal(minimalResult)
531+
if err != nil {
532+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
533+
}
534+
535+
return utils.NewToolResultText(string(r)), nil, nil
536+
},
537+
)
538+
}

0 commit comments

Comments
 (0)