From 081babf78645e634df861137e4e48eed7243bde6 Mon Sep 17 00:00:00 2001 From: Kowshik4593 Date: Sun, 12 Jul 2026 01:32:27 +0530 Subject: [PATCH] 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. --- README.md | 10 + pkg/github/__toolsnaps__/compare_commits.snap | 57 ++++++ pkg/github/helper_test.go | 3 + pkg/github/minimal_types.go | 72 +++++++ pkg/github/repositories.go | 119 ++++++++++++ pkg/github/repositories_test.go | 175 ++++++++++++++++++ pkg/github/tools.go | 1 + 7 files changed, 437 insertions(+) create mode 100644 pkg/github/__toolsnaps__/compare_commits.snap diff --git a/README.md b/README.md index 10d987a262..d23832599c 100644 --- a/README.md +++ b/README.md @@ -1243,6 +1243,16 @@ The following sets of tools are available: repo Repositories +- **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) diff --git a/pkg/github/__toolsnaps__/compare_commits.snap b/pkg/github/__toolsnaps__/compare_commits.snap new file mode 100644 index 0000000000..eb4c1a1911 --- /dev/null +++ b/pkg/github/__toolsnaps__/compare_commits.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index c5a73d9667..1d6cb85cc4 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -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}" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 75bc8f48f1..811ee968d1 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -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). @@ -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. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index aa236cd53a..2fde6f2fa1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -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( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index fcc5fa0634..f97e226c14 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -5680,3 +5680,178 @@ func Test_ListRepositoryCollaborators(t *testing.T) { }) } } + +func Test_CompareCommits(t *testing.T) { + serverTool := CompareCommits(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "compare_commits", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "base") + assert.Contains(t, schema.Properties, "head") + assert.Contains(t, schema.Properties, "detail") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "base", "head"}) + + mockComparison := &github.CommitsComparison{ + Status: github.Ptr("ahead"), + AheadBy: github.Ptr(2), + BehindBy: github.Ptr(0), + TotalCommits: github.Ptr(2), + BaseCommit: &github.RepositoryCommit{ + SHA: github.Ptr("base-sha"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/base-sha"), + Commit: &github.Commit{Message: github.Ptr("base commit")}, + }, + Commits: []*github.RepositoryCommit{ + { + SHA: github.Ptr("commit-sha-1"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/commit-sha-1"), + Commit: &github.Commit{Message: github.Ptr("first commit")}, + }, + { + SHA: github.Ptr("commit-sha-2"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/commit-sha-2"), + Commit: &github.Commit{Message: github.Ptr("second commit")}, + }, + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("main.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(5), + Deletions: github.Ptr(1), + Changes: github.Ptr(6), + Patch: github.Ptr("@@ -1,1 +1,5 @@"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + verify func(t *testing.T, comparison MinimalCommitsComparison) + }{ + { + name: "successful comparison with default detail", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposCompareByOwnerByRepoByBasehead, mockComparison), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "base": "main", + "head": "feature", + }, + expectError: false, + verify: func(t *testing.T, comparison MinimalCommitsComparison) { + assert.Equal(t, "ahead", comparison.Status) + assert.Equal(t, 2, comparison.AheadBy) + assert.Len(t, comparison.Commits, 2) + assert.Equal(t, "commit-sha-1", comparison.Commits[0].SHA) + require.Len(t, comparison.Files, 1) + assert.Equal(t, "main.go", comparison.Files[0].Filename) + // Default detail is "stats": no patch content. + assert.Empty(t, comparison.Files[0].Patch) + }, + }, + { + name: "successful comparison with full_patch detail", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposCompareByOwnerByRepoByBasehead, mockComparison), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "base": "main", + "head": "feature", + "detail": "full_patch", + }, + expectError: false, + verify: func(t *testing.T, comparison MinimalCommitsComparison) { + require.Len(t, comparison.Files, 1) + assert.Equal(t, "@@ -1,1 +1,5 @@", comparison.Files[0].Patch) + }, + }, + { + name: "successful comparison with none detail omits files", + mockedClient: NewMockedHTTPClient( + WithRequestMatch(GetReposCompareByOwnerByRepoByBasehead, mockComparison), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "base": "main", + "head": "feature", + "detail": "none", + }, + expectError: false, + verify: func(t *testing.T, comparison MinimalCommitsComparison) { + assert.Empty(t, comparison.Files) + }, + }, + { + name: "missing base parameter", + mockedClient: NewMockedHTTPClient(), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "head": "feature"}, + expectError: false, + expectedErrMsg: "missing required parameter: base", + }, + { + name: "comparison fails", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposCompareByOwnerByRepoByBasehead, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "base": "main", + "head": "does-not-exist", + }, + expectError: false, + expectedErrMsg: "failed to compare commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var comparison MinimalCommitsComparison + err = json.Unmarshal([]byte(textContent.Text), &comparison) + require.NoError(t, err) + + tc.verify(t, comparison) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 5c6123c277..5cccb5e419 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -189,6 +189,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { LegacySearchCode(t), SearchCommits(t), GetCommit(t), + CompareCommits(t), GetFileBlame(t), ListBranches(t), ListTags(t),