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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,16 @@ The following sets of tools are available:

<summary><picture><source media="(prefers-color-scheme: dark)" srcset="pkg/octicons/icons/repo-dark.png"><source media="(prefers-color-scheme: light)" srcset="pkg/octicons/icons/repo-light.png"><img src="pkg/octicons/icons/repo-light.png" width="20" height="20" alt="repo"></picture> Repositories</summary>

- **compare_commits** - Compare two commits
- **Required OAuth Scopes**: `repo`
- `base`: Base branch, tag, or commit SHA to compare from (string, required)
- `detail`: Level of detail to include for changed files. "none" omits stats and files entirely. "stats" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. "full_patch" additionally includes the unified diff content for each file and can be very large. (string, optional)
- `head`: Head branch, tag, or commit SHA to compare to (string, required)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)

- **create_branch** - Create branch
- **Required OAuth Scopes**: `repo`
- `branch`: Name for new branch (string, required)
Expand Down
57 changes: 57 additions & 0 deletions pkg/github/__toolsnaps__/compare_commits.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"annotations": {
"idempotentHint": false,
"readOnlyHint": true,
"title": "Compare two commits"
},
"description": "Compare two commits, branches, or tags in a GitHub repository, returning the ahead/behind commit counts, the list of commits unique to head, and the files changed between them",
"inputSchema": {
"properties": {
"base": {
"description": "Base branch, tag, or commit SHA to compare from",
"type": "string"
},
"detail": {
"default": "stats",
"description": "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.",
"enum": [
"none",
"stats",
"full_patch"
],
"type": "string"
},
"head": {
"description": "Head branch, tag, or commit SHA to compare to",
"type": "string"
},
"owner": {
"description": "Repository owner",
"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"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"base",
"head"
],
"type": "object"
},
"name": "compare_commits"
}
3 changes: 3 additions & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ const (
GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest"
GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}"

// Commits endpoints
GetReposCompareByOwnerByRepoByBasehead = "GET /repos/{owner}/{repo}/compare/{basehead}"

// Code quality endpoints
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}"

Expand Down
72 changes: 72 additions & 0 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,24 @@ type MinimalCommit struct {
Files []MinimalCommitFile `json:"files,omitempty"`
}

// MinimalCommitsComparison is the trimmed output type for a two-commit
// comparison (compare_commits), mirroring github.CommitsComparison with
// commits trimmed down to MinimalCommit.
type MinimalCommitsComparison struct {
Status string `json:"status,omitempty"`
AheadBy int `json:"ahead_by"`
BehindBy int `json:"behind_by"`
TotalCommits int `json:"total_commits"`
BaseCommit *MinimalCommit `json:"base_commit,omitempty"`
MergeBaseCommit *MinimalCommit `json:"merge_base_commit,omitempty"`
Commits []MinimalCommit `json:"commits,omitempty"`
Files []MinimalCommitFile `json:"files,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
PermalinkURL string `json:"permalink_url,omitempty"`
DiffURL string `json:"diff_url,omitempty"`
PatchURL string `json:"patch_url,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).
Expand Down Expand Up @@ -1654,6 +1672,60 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail
return minimalCommit
}

// convertToMinimalCommitsComparison converts a two-commit comparison
// (compare_commits) to its trimmed form. The base/merge-base/head commits are
// always converted without per-file detail (commitDetailNone), matching how
// convertToMinimalCommit is used for list_commits, since the compare API
// doesn't populate per-commit file data. detail instead controls the
// top-level Files list, which holds the actual diff between base and head.
func convertToMinimalCommitsComparison(comp *github.CommitsComparison, detail commitDetail) MinimalCommitsComparison {
minimalComparison := MinimalCommitsComparison{
Status: comp.GetStatus(),
AheadBy: comp.GetAheadBy(),
BehindBy: comp.GetBehindBy(),
TotalCommits: comp.GetTotalCommits(),
HTMLURL: comp.GetHTMLURL(),
PermalinkURL: comp.GetPermalinkURL(),
DiffURL: comp.GetDiffURL(),
PatchURL: comp.GetPatchURL(),
}

if comp.BaseCommit != nil {
baseCommit := convertToMinimalCommit(comp.BaseCommit, commitDetailNone)
minimalComparison.BaseCommit = &baseCommit
}
if comp.MergeBaseCommit != nil {
mergeBaseCommit := convertToMinimalCommit(comp.MergeBaseCommit, commitDetailNone)
minimalComparison.MergeBaseCommit = &mergeBaseCommit
}

if len(comp.Commits) > 0 {
minimalComparison.Commits = make([]MinimalCommit, len(comp.Commits))
for i, commit := range comp.Commits {
minimalComparison.Commits[i] = convertToMinimalCommit(commit, commitDetailNone)
}
}

if detail != commitDetailNone && len(comp.Files) > 0 {
minimalComparison.Files = make([]MinimalCommitFile, 0, len(comp.Files))
for _, file := range comp.Files {
minimalFile := MinimalCommitFile{
Filename: file.GetFilename(),
Status: file.GetStatus(),
Additions: file.GetAdditions(),
Deletions: file.GetDeletions(),
Changes: file.GetChanges(),
}
if detail == commitDetailFullPatch {
minimalFile.Patch = file.GetPatch()
}
minimalComparison.Files = append(minimalComparison.Files, minimalFile)
}
}

return minimalComparison
}

// convertCommitResultToMinimalCommit converts a GitHub API commit search
// result, attaching the containing repository so the caller can tell which
// repo each result came from.
Expand Down
119 changes: 119 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,125 @@ func listCommitsTool(t translations.TranslationHelperFunc, includeFields bool) i
)
}

// CompareCommits creates a tool to compare two commits, branches, or tags in a GitHub repository.
func CompareCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
schema := &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"base": {
Type: "string",
Description: "Base branch, tag, or commit SHA to compare from",
},
"head": {
Type: "string",
Description: "Head branch, tag, or commit SHA to compare to",
},
"detail": {
Type: "string",
Enum: []any{"none", "stats", "full_patch"},
Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.",
Default: json.RawMessage(`"stats"`),
},
},
Required: []string{"owner", "repo", "base", "head"},
}
WithPagination(schema)

return NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "compare_commits",
Description: t("TOOL_COMPARE_COMMITS_DESCRIPTION", "Compare two commits, branches, or tags in a GitHub repository, returning the ahead/behind commit counts, the list of commits unique to head, and the files changed between them"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_COMPARE_COMMITS_USER_TITLE", "Compare two 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) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
base, err := RequiredParam[string](args, "base")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
head, err := RequiredParam[string](args, "head")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
detailRaw, err := OptionalParam[string](args, "detail")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
detail, err := parseCommitDetail(detailRaw)
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.ListOptions{
Page: pagination.Page,
PerPage: pagination.PerPage,
}

client, err := deps.GetClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}

comparison, resp, err := client.Repositories.CompareCommits(ctx, owner, repo, base, head, opts)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to compare commits: %s...%s", base, head),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to compare commits", resp, body), nil, nil
}

minimalComparison := convertToMinimalCommitsComparison(comparison, detail)

r, err := json.Marshal(minimalComparison)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}

result := utils.NewToolResultText(string(r))
// Commit content is reachable from the repo's history; integrity
// follows the same public-untrusted / private-trusted rule as file
// contents. Confidentiality follows repo visibility.
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents)
return result, nil, nil
},
)
}

// ListBranches creates a tool to list branches in a GitHub repository.
func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool {
return NewTool(
Expand Down
Loading