Skip to content

Commit 6b4ca78

Browse files
Not-Dhananjay-MishraCopilotSamMorrowDrumsCopilot
authored
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 0bd0bf0 commit 6b4ca78

7 files changed

Lines changed: 414 additions & 26 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1302,6 +1302,14 @@ The following sets of tools are available:
13021302
- `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required)
13031303
- `sort`: Sort field ('indexed' only) (string, optional)
13041304

1305+
- **search_commits** - Search commits
1306+
- **Required OAuth Scopes**: `repo`
1307+
- `order`: Sort order (string, optional)
1308+
- `page`: Page number for pagination (min 1) (number, optional)
1309+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
1310+
- `query`: 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`. (string, required)
1311+
- `sort`: Sort by author or committer date (defaults to best match) (string, optional)
1312+
13051313
- **search_repositories** - Search repositories
13061314
- **Required OAuth Scopes**: `repo`
13071315
- `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional)
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
@@ -140,6 +140,7 @@ const (
140140
GetSearchIssues = "GET /search/issues"
141141
GetSearchUsers = "GET /search/users"
142142
GetSearchRepositories = "GET /search/repositories"
143+
GetSearchCommits = "GET /search/commits"
143144

144145
// Raw content endpoints (used for GitHub raw content API, not standard API)
145146
// 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
@@ -130,6 +130,23 @@ type MinimalCommit struct {
130130
Files []MinimalCommitFile `json:"files,omitempty"`
131131
}
132132

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

274+
// MinimalSearchCommitsResult is the trimmed output type for commit search results.
275+
type MinimalSearchCommitsResult struct {
276+
TotalCount int `json:"total_count"`
277+
IncompleteResults bool `json:"incomplete_results"`
278+
Items []MinimalCommitSearchItem `json:"items"`
279+
}
280+
257281
// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
258282
type MinimalFileContentResponse struct {
259283
Content *MinimalFileContent `json:"content,omitempty"`
@@ -693,57 +717,73 @@ func convertToMinimalUser(user *github.User) *MinimalUser {
693717
}
694718
}
695719

696-
// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
697-
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
720+
// newMinimalCommitFromCore builds a MinimalCommit from the fields that are
721+
// shared between *github.RepositoryCommit and *github.CommitResult. Caller
722+
// is responsible for setting any type-specific extras (stats/files for
723+
// RepositoryCommit, repository for CommitResult).
724+
func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author, committer *github.User) MinimalCommit {
698725
minimalCommit := MinimalCommit{
699-
SHA: commit.GetSHA(),
700-
HTMLURL: commit.GetHTMLURL(),
726+
SHA: sha,
727+
HTMLURL: htmlURL,
701728
}
702729

703-
if commit.Commit != nil {
730+
if commit != nil {
704731
minimalCommit.Commit = &MinimalCommitInfo{
705-
Message: commit.Commit.GetMessage(),
732+
Message: commit.GetMessage(),
706733
}
707734

708-
if commit.Commit.Author != nil {
735+
if commit.Author != nil {
709736
minimalCommit.Commit.Author = &MinimalCommitAuthor{
710-
Name: commit.Commit.Author.GetName(),
711-
Email: commit.Commit.Author.GetEmail(),
737+
Name: commit.Author.GetName(),
738+
Email: commit.Author.GetEmail(),
712739
}
713-
if commit.Commit.Author.Date != nil {
714-
minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339)
740+
if commit.Author.Date != nil {
741+
minimalCommit.Commit.Author.Date = commit.Author.Date.Format(time.RFC3339)
715742
}
716743
}
717744

718-
if commit.Commit.Committer != nil {
745+
if commit.Committer != nil {
719746
minimalCommit.Commit.Committer = &MinimalCommitAuthor{
720-
Name: commit.Commit.Committer.GetName(),
721-
Email: commit.Commit.Committer.GetEmail(),
747+
Name: commit.Committer.GetName(),
748+
Email: commit.Committer.GetEmail(),
722749
}
723-
if commit.Commit.Committer.Date != nil {
724-
minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339)
750+
if commit.Committer.Date != nil {
751+
minimalCommit.Commit.Committer.Date = commit.Committer.Date.Format(time.RFC3339)
725752
}
726753
}
727754
}
728755

729-
if commit.Author != nil {
756+
if author != nil {
730757
minimalCommit.Author = &MinimalUser{
731-
Login: commit.Author.GetLogin(),
732-
ID: commit.Author.GetID(),
733-
ProfileURL: commit.Author.GetHTMLURL(),
734-
AvatarURL: commit.Author.GetAvatarURL(),
758+
Login: author.GetLogin(),
759+
ID: author.GetID(),
760+
ProfileURL: author.GetHTMLURL(),
761+
AvatarURL: author.GetAvatarURL(),
735762
}
736763
}
737764

738-
if commit.Committer != nil {
765+
if committer != nil {
739766
minimalCommit.Committer = &MinimalUser{
740-
Login: commit.Committer.GetLogin(),
741-
ID: commit.Committer.GetID(),
742-
ProfileURL: commit.Committer.GetHTMLURL(),
743-
AvatarURL: commit.Committer.GetAvatarURL(),
767+
Login: committer.GetLogin(),
768+
ID: committer.GetID(),
769+
ProfileURL: committer.GetHTMLURL(),
770+
AvatarURL: committer.GetAvatarURL(),
744771
}
745772
}
746773

774+
return minimalCommit
775+
}
776+
777+
// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
778+
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
779+
minimalCommit := newMinimalCommitFromCore(
780+
commit.GetSHA(),
781+
commit.GetHTMLURL(),
782+
commit.Commit,
783+
commit.Author,
784+
commit.Committer,
785+
)
786+
747787
// Only include stats and files if includeDiffs is true
748788
if includeDiffs {
749789
if commit.Stats != nil {
@@ -772,6 +812,31 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool)
772812
return minimalCommit
773813
}
774814

815+
// convertCommitResultToMinimalCommit converts a GitHub API commit search
816+
// result, attaching the containing repository so the caller can tell which
817+
// repo each result came from.
818+
func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalCommitSearchItem {
819+
item := MinimalCommitSearchItem{
820+
MinimalCommit: newMinimalCommitFromCore(
821+
commit.GetSHA(),
822+
commit.GetHTMLURL(),
823+
commit.Commit,
824+
commit.Author,
825+
commit.Committer,
826+
),
827+
}
828+
829+
if commit.Repository != nil {
830+
item.Repository = &MinimalRepoRef{
831+
FullName: commit.Repository.GetFullName(),
832+
HTMLURL: commit.Repository.GetHTMLURL(),
833+
Private: commit.Repository.GetPrivate(),
834+
}
835+
}
836+
837+
return item
838+
}
839+
775840
// MinimalPageInfo contains pagination cursor information.
776841
type MinimalPageInfo struct {
777842
HasNextPage bool `json:"hasNextPage"`

pkg/github/search.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,109 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool {
478478
},
479479
)
480480
}
481+
482+
// SearchCommits creates a tool to search for commits across GitHub repositories.
483+
func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
484+
schema := &jsonschema.Schema{
485+
Type: "object",
486+
Properties: map[string]*jsonschema.Schema{
487+
"query": {
488+
Type: "string",
489+
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`.",
490+
},
491+
"sort": {
492+
Type: "string",
493+
Description: "Sort by author or committer date (defaults to best match)",
494+
Enum: []any{"author-date", "committer-date"},
495+
},
496+
"order": {
497+
Type: "string",
498+
Description: "Sort order",
499+
Enum: []any{"asc", "desc"},
500+
},
501+
},
502+
Required: []string{"query"},
503+
}
504+
WithPagination(schema)
505+
506+
return NewTool(
507+
ToolsetMetadataRepos,
508+
mcp.Tool{
509+
Name: "search_commits",
510+
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."),
511+
Annotations: &mcp.ToolAnnotations{
512+
Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"),
513+
ReadOnlyHint: true,
514+
},
515+
InputSchema: schema,
516+
},
517+
[]scopes.Scope{scopes.Repo},
518+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
519+
query, err := RequiredParam[string](args, "query")
520+
if err != nil {
521+
return utils.NewToolResultError(err.Error()), nil, nil
522+
}
523+
sort, err := OptionalParam[string](args, "sort")
524+
if err != nil {
525+
return utils.NewToolResultError(err.Error()), nil, nil
526+
}
527+
order, err := OptionalParam[string](args, "order")
528+
if err != nil {
529+
return utils.NewToolResultError(err.Error()), nil, nil
530+
}
531+
pagination, err := OptionalPaginationParams(args)
532+
if err != nil {
533+
return utils.NewToolResultError(err.Error()), nil, nil
534+
}
535+
536+
opts := &github.SearchOptions{
537+
Sort: sort,
538+
Order: order,
539+
ListOptions: github.ListOptions{
540+
Page: pagination.Page,
541+
PerPage: pagination.PerPage,
542+
},
543+
}
544+
545+
client, err := deps.GetClient(ctx)
546+
if err != nil {
547+
return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil
548+
}
549+
result, resp, err := client.Search.Commits(ctx, query, opts)
550+
if err != nil {
551+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
552+
fmt.Sprintf("failed to search commits with query '%s'", query),
553+
resp,
554+
err,
555+
), nil, nil
556+
}
557+
defer func() { _ = resp.Body.Close() }()
558+
559+
if resp.StatusCode != http.StatusOK {
560+
body, err := io.ReadAll(resp.Body)
561+
if err != nil {
562+
return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil
563+
}
564+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil
565+
}
566+
567+
minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits))
568+
for _, commit := range result.Commits {
569+
minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit))
570+
}
571+
572+
minimalResult := &MinimalSearchCommitsResult{
573+
TotalCount: result.GetTotal(),
574+
IncompleteResults: result.GetIncompleteResults(),
575+
Items: minimalCommits,
576+
}
577+
578+
r, err := json.Marshal(minimalResult)
579+
if err != nil {
580+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
581+
}
582+
583+
return utils.NewToolResultText(string(r)), nil, nil
584+
},
585+
)
586+
}

0 commit comments

Comments
 (0)