Skip to content
Merged
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 @@ -1302,6 +1302,14 @@ The following sets of tools are available:
- `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)
- `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 (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)
- `sort`: Sort by author or committer date (defaults to best match) (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 GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only.",
"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 (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`.",
"type": "string"
},
"sort": {
"description": "Sort by author or committer date (defaults to best match)",
"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 @@ -140,6 +140,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
117 changes: 91 additions & 26 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,23 @@ type MinimalCommit struct {
Files []MinimalCommitFile `json:"files,omitempty"`
}

// MinimalRepoRef is a lightweight reference to a repository, used when a
// result needs to identify which repository it belongs to (for example, in
// cross-repo commit search results).
type MinimalRepoRef struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url,omitempty"`
Private bool `json:"private,omitempty"`
}

// MinimalCommitSearchItem extends MinimalCommit with the containing
// repository, since commit search spans repositories and callers need to
// know which repo each result came from.
type MinimalCommitSearchItem struct {
MinimalCommit
Repository *MinimalRepoRef `json:"repository,omitempty"`
}

// MinimalRelease is the trimmed output type for release objects.
type MinimalRelease struct {
ID int64 `json:"id"`
Expand Down Expand Up @@ -254,6 +271,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 []MinimalCommitSearchItem `json:"items"`
}

// MinimalFileContentResponse is the trimmed output type for create/update/delete file responses.
type MinimalFileContentResponse struct {
Content *MinimalFileContent `json:"content,omitempty"`
Expand Down Expand Up @@ -693,57 +717,73 @@ func convertToMinimalUser(user *github.User) *MinimalUser {
}
}

// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
// newMinimalCommitFromCore builds a MinimalCommit from the fields that are
// shared between *github.RepositoryCommit and *github.CommitResult. Caller
// is responsible for setting any type-specific extras (stats/files for
// RepositoryCommit, repository for CommitResult).
func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author, committer *github.User) MinimalCommit {
minimalCommit := MinimalCommit{
SHA: commit.GetSHA(),
HTMLURL: commit.GetHTMLURL(),
SHA: sha,
HTMLURL: htmlURL,
}

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

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

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

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

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

return minimalCommit
}

// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit
func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit {
minimalCommit := newMinimalCommitFromCore(
commit.GetSHA(),
commit.GetHTMLURL(),
commit.Commit,
commit.Author,
commit.Committer,
)

// Only include stats and files if includeDiffs is true
if includeDiffs {
if commit.Stats != nil {
Expand Down Expand Up @@ -772,6 +812,31 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool)
return minimalCommit
}

// convertCommitResultToMinimalCommit converts a GitHub API commit search
// result, attaching the containing repository so the caller can tell which
// repo each result came from.
func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalCommitSearchItem {
item := MinimalCommitSearchItem{
MinimalCommit: newMinimalCommitFromCore(
commit.GetSHA(),
commit.GetHTMLURL(),
commit.Commit,
commit.Author,
commit.Committer,
),
}

if commit.Repository != nil {
item.Repository = &MinimalRepoRef{
FullName: commit.Repository.GetFullName(),
HTMLURL: commit.Repository.GetHTMLURL(),
Private: commit.Repository.GetPrivate(),
}
}

return item
}

// MinimalPageInfo contains pagination cursor information.
type MinimalPageInfo struct {
HasNextPage bool `json:"hasNextPage"`
Expand Down
106 changes: 106 additions & 0 deletions pkg/github/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,3 +478,109 @@ 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 (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`.",
},
"sort": {
Type: "string",
Description: "Sort by author or committer date (defaults to best match)",
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 GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."),
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([]MinimalCommitSearchItem, 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
},
)
}
Loading
Loading