Skip to content

Commit 081babf

Browse files
committed
Add compare_commits tool
Adds a compare_commits tool wrapping the GitHub "Compare two commits" REST endpoint (GET /repos/{owner}/{repo}/compare/{base}...{head}), so agents can diff two branches, tags, or commit SHAs directly instead of approximating it with two list_commits calls. Returns ahead/behind counts, the commits unique to head, and the changed files, trimmed via a new MinimalCommitsComparison type. A `detail` parameter (none/stats/full_patch, matching get_commit) controls how much per-file diff content is included.
1 parent c36e4e4 commit 081babf

7 files changed

Lines changed: 437 additions & 0 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,6 +1243,16 @@ The following sets of tools are available:
12431243

12441244
<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>
12451245

1246+
- **compare_commits** - Compare two commits
1247+
- **Required OAuth Scopes**: `repo`
1248+
- `base`: Base branch, tag, or commit SHA to compare from (string, required)
1249+
- `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)
1250+
- `head`: Head branch, tag, or commit SHA to compare to (string, required)
1251+
- `owner`: Repository owner (string, required)
1252+
- `page`: Page number for pagination (min 1) (number, optional)
1253+
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
1254+
- `repo`: Repository name (string, required)
1255+
12461256
- **create_branch** - Create branch
12471257
- **Required OAuth Scopes**: `repo`
12481258
- `branch`: Name for new branch (string, required)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"annotations": {
3+
"idempotentHint": false,
4+
"readOnlyHint": true,
5+
"title": "Compare two commits"
6+
},
7+
"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",
8+
"inputSchema": {
9+
"properties": {
10+
"base": {
11+
"description": "Base branch, tag, or commit SHA to compare from",
12+
"type": "string"
13+
},
14+
"detail": {
15+
"default": "stats",
16+
"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.",
17+
"enum": [
18+
"none",
19+
"stats",
20+
"full_patch"
21+
],
22+
"type": "string"
23+
},
24+
"head": {
25+
"description": "Head branch, tag, or commit SHA to compare to",
26+
"type": "string"
27+
},
28+
"owner": {
29+
"description": "Repository owner",
30+
"type": "string"
31+
},
32+
"page": {
33+
"description": "Page number for pagination (min 1)",
34+
"minimum": 1,
35+
"type": "number"
36+
},
37+
"perPage": {
38+
"description": "Results per page for pagination (min 1, max 100)",
39+
"maximum": 100,
40+
"minimum": 1,
41+
"type": "number"
42+
},
43+
"repo": {
44+
"description": "Repository name",
45+
"type": "string"
46+
}
47+
},
48+
"required": [
49+
"owner",
50+
"repo",
51+
"base",
52+
"head"
53+
],
54+
"type": "object"
55+
},
56+
"name": "compare_commits"
57+
}

pkg/github/helper_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ const (
107107
GetReposReleasesLatestByOwnerByRepo = "GET /repos/{owner}/{repo}/releases/latest"
108108
GetReposReleasesTagsByOwnerByRepoByTag = "GET /repos/{owner}/{repo}/releases/tags/{tag}"
109109

110+
// Commits endpoints
111+
GetReposCompareByOwnerByRepoByBasehead = "GET /repos/{owner}/{repo}/compare/{basehead}"
112+
110113
// Code quality endpoints
111114
GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber = "GET /repos/{owner}/{repo}/code-quality/findings/{finding_number}"
112115

pkg/github/minimal_types.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,24 @@ type MinimalCommit struct {
275275
Files []MinimalCommitFile `json:"files,omitempty"`
276276
}
277277

278+
// MinimalCommitsComparison is the trimmed output type for a two-commit
279+
// comparison (compare_commits), mirroring github.CommitsComparison with
280+
// commits trimmed down to MinimalCommit.
281+
type MinimalCommitsComparison struct {
282+
Status string `json:"status,omitempty"`
283+
AheadBy int `json:"ahead_by"`
284+
BehindBy int `json:"behind_by"`
285+
TotalCommits int `json:"total_commits"`
286+
BaseCommit *MinimalCommit `json:"base_commit,omitempty"`
287+
MergeBaseCommit *MinimalCommit `json:"merge_base_commit,omitempty"`
288+
Commits []MinimalCommit `json:"commits,omitempty"`
289+
Files []MinimalCommitFile `json:"files,omitempty"`
290+
HTMLURL string `json:"html_url,omitempty"`
291+
PermalinkURL string `json:"permalink_url,omitempty"`
292+
DiffURL string `json:"diff_url,omitempty"`
293+
PatchURL string `json:"patch_url,omitempty"`
294+
}
295+
278296
// MinimalRepoRef is a lightweight reference to a repository, used when a
279297
// result needs to identify which repository it belongs to (for example, in
280298
// cross-repo commit search results).
@@ -1654,6 +1672,60 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail
16541672
return minimalCommit
16551673
}
16561674

1675+
// convertToMinimalCommitsComparison converts a two-commit comparison
1676+
// (compare_commits) to its trimmed form. The base/merge-base/head commits are
1677+
// always converted without per-file detail (commitDetailNone), matching how
1678+
// convertToMinimalCommit is used for list_commits, since the compare API
1679+
// doesn't populate per-commit file data. detail instead controls the
1680+
// top-level Files list, which holds the actual diff between base and head.
1681+
func convertToMinimalCommitsComparison(comp *github.CommitsComparison, detail commitDetail) MinimalCommitsComparison {
1682+
minimalComparison := MinimalCommitsComparison{
1683+
Status: comp.GetStatus(),
1684+
AheadBy: comp.GetAheadBy(),
1685+
BehindBy: comp.GetBehindBy(),
1686+
TotalCommits: comp.GetTotalCommits(),
1687+
HTMLURL: comp.GetHTMLURL(),
1688+
PermalinkURL: comp.GetPermalinkURL(),
1689+
DiffURL: comp.GetDiffURL(),
1690+
PatchURL: comp.GetPatchURL(),
1691+
}
1692+
1693+
if comp.BaseCommit != nil {
1694+
baseCommit := convertToMinimalCommit(comp.BaseCommit, commitDetailNone)
1695+
minimalComparison.BaseCommit = &baseCommit
1696+
}
1697+
if comp.MergeBaseCommit != nil {
1698+
mergeBaseCommit := convertToMinimalCommit(comp.MergeBaseCommit, commitDetailNone)
1699+
minimalComparison.MergeBaseCommit = &mergeBaseCommit
1700+
}
1701+
1702+
if len(comp.Commits) > 0 {
1703+
minimalComparison.Commits = make([]MinimalCommit, len(comp.Commits))
1704+
for i, commit := range comp.Commits {
1705+
minimalComparison.Commits[i] = convertToMinimalCommit(commit, commitDetailNone)
1706+
}
1707+
}
1708+
1709+
if detail != commitDetailNone && len(comp.Files) > 0 {
1710+
minimalComparison.Files = make([]MinimalCommitFile, 0, len(comp.Files))
1711+
for _, file := range comp.Files {
1712+
minimalFile := MinimalCommitFile{
1713+
Filename: file.GetFilename(),
1714+
Status: file.GetStatus(),
1715+
Additions: file.GetAdditions(),
1716+
Deletions: file.GetDeletions(),
1717+
Changes: file.GetChanges(),
1718+
}
1719+
if detail == commitDetailFullPatch {
1720+
minimalFile.Patch = file.GetPatch()
1721+
}
1722+
minimalComparison.Files = append(minimalComparison.Files, minimalFile)
1723+
}
1724+
}
1725+
1726+
return minimalComparison
1727+
}
1728+
16571729
// convertCommitResultToMinimalCommit converts a GitHub API commit search
16581730
// result, attaching the containing repository so the caller can tell which
16591731
// repo each result came from.

pkg/github/repositories.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,125 @@ func listCommitsTool(t translations.TranslationHelperFunc, includeFields bool) i
341341
)
342342
}
343343

344+
// CompareCommits creates a tool to compare two commits, branches, or tags in a GitHub repository.
345+
func CompareCommits(t translations.TranslationHelperFunc) inventory.ServerTool {
346+
schema := &jsonschema.Schema{
347+
Type: "object",
348+
Properties: map[string]*jsonschema.Schema{
349+
"owner": {
350+
Type: "string",
351+
Description: "Repository owner",
352+
},
353+
"repo": {
354+
Type: "string",
355+
Description: "Repository name",
356+
},
357+
"base": {
358+
Type: "string",
359+
Description: "Base branch, tag, or commit SHA to compare from",
360+
},
361+
"head": {
362+
Type: "string",
363+
Description: "Head branch, tag, or commit SHA to compare to",
364+
},
365+
"detail": {
366+
Type: "string",
367+
Enum: []any{"none", "stats", "full_patch"},
368+
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.",
369+
Default: json.RawMessage(`"stats"`),
370+
},
371+
},
372+
Required: []string{"owner", "repo", "base", "head"},
373+
}
374+
WithPagination(schema)
375+
376+
return NewTool(
377+
ToolsetMetadataRepos,
378+
mcp.Tool{
379+
Name: "compare_commits",
380+
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"),
381+
Annotations: &mcp.ToolAnnotations{
382+
Title: t("TOOL_COMPARE_COMMITS_USER_TITLE", "Compare two commits"),
383+
ReadOnlyHint: true,
384+
},
385+
InputSchema: schema,
386+
},
387+
[]scopes.Scope{scopes.Repo},
388+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
389+
owner, err := RequiredParam[string](args, "owner")
390+
if err != nil {
391+
return utils.NewToolResultError(err.Error()), nil, nil
392+
}
393+
repo, err := RequiredParam[string](args, "repo")
394+
if err != nil {
395+
return utils.NewToolResultError(err.Error()), nil, nil
396+
}
397+
base, err := RequiredParam[string](args, "base")
398+
if err != nil {
399+
return utils.NewToolResultError(err.Error()), nil, nil
400+
}
401+
head, err := RequiredParam[string](args, "head")
402+
if err != nil {
403+
return utils.NewToolResultError(err.Error()), nil, nil
404+
}
405+
detailRaw, err := OptionalParam[string](args, "detail")
406+
if err != nil {
407+
return utils.NewToolResultError(err.Error()), nil, nil
408+
}
409+
detail, err := parseCommitDetail(detailRaw)
410+
if err != nil {
411+
return utils.NewToolResultError(err.Error()), nil, nil
412+
}
413+
pagination, err := OptionalPaginationParams(args)
414+
if err != nil {
415+
return utils.NewToolResultError(err.Error()), nil, nil
416+
}
417+
418+
opts := &github.ListOptions{
419+
Page: pagination.Page,
420+
PerPage: pagination.PerPage,
421+
}
422+
423+
client, err := deps.GetClient(ctx)
424+
if err != nil {
425+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
426+
}
427+
428+
comparison, resp, err := client.Repositories.CompareCommits(ctx, owner, repo, base, head, opts)
429+
if err != nil {
430+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
431+
fmt.Sprintf("failed to compare commits: %s...%s", base, head),
432+
resp,
433+
err,
434+
), nil, nil
435+
}
436+
defer func() { _ = resp.Body.Close() }()
437+
438+
if resp.StatusCode != http.StatusOK {
439+
body, err := io.ReadAll(resp.Body)
440+
if err != nil {
441+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
442+
}
443+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to compare commits", resp, body), nil, nil
444+
}
445+
446+
minimalComparison := convertToMinimalCommitsComparison(comparison, detail)
447+
448+
r, err := json.Marshal(minimalComparison)
449+
if err != nil {
450+
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
451+
}
452+
453+
result := utils.NewToolResultText(string(r))
454+
// Commit content is reachable from the repo's history; integrity
455+
// follows the same public-untrusted / private-trusted rule as file
456+
// contents. Confidentiality follows repo visibility.
457+
result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents)
458+
return result, nil, nil
459+
},
460+
)
461+
}
462+
344463
// ListBranches creates a tool to list branches in a GitHub repository.
345464
func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool {
346465
return NewTool(

0 commit comments

Comments
 (0)