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
40 changes: 40 additions & 0 deletions pkg/github/__toolsnaps__/compare_file_contents.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Compare file contents"
},
"description": "Compare a file between two git refs, with semantic diffs for structured formats (JSON, YAML)",
"inputSchema": {
"properties": {
"base": {
"description": "Base git ref to compare from (commit SHA, branch name, or tag)",
"type": "string"
},
"head": {
"description": "Head git ref to compare to (commit SHA, branch name, or tag)",
"type": "string"
},
"owner": {
"description": "Repository owner (username or organization)",
"type": "string"
},
"path": {
"description": "Path to the file to compare",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo",
"path",
"base",
"head"
],
"type": "object"
},
"name": "compare_file_contents"
}
138 changes: 138 additions & 0 deletions pkg/github/compare.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package github

import (
"context"
"fmt"
"io"
"net/http"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

// CompareFileContents creates a tool to compare a file between two git refs,
// producing semantic diffs for structured formats (JSON, YAML) and falling back
// to unified diffs for unsupported formats.
func CompareFileContents(t translations.TranslationHelperFunc) inventory.ServerTool {
st := NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "compare_file_contents",
Description: t("TOOL_COMPARE_FILE_CONTENTS_DESCRIPTION", "Compare a file between two git refs, with semantic diffs for structured formats (JSON, YAML)"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_COMPARE_FILE_CONTENTS_USER_TITLE", "Compare file contents"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
"path": {
Type: "string",
Description: "Path to the file to compare",
},
"base": {
Type: "string",
Description: "Base git ref to compare from (commit SHA, branch name, or tag)",
},
"head": {
Type: "string",
Description: "Head git ref to compare to (commit SHA, branch name, or tag)",
},
},
Required: []string{"owner", "repo", "path", "base", "head"},
},
},
[]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
}
path, err := RequiredParam[string](args, "path")
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
}

rawClient, err := deps.GetRawClient(ctx)
if err != nil {
return utils.NewToolResultError("failed to get raw content client"), nil, nil
}

baseContent, err := fetchFileContent(ctx, rawClient, owner, repo, path, base)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to fetch base file: %s", err)), nil, nil
}

headContent, err := fetchFileContent(ctx, rawClient, owner, repo, path, head)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to fetch head file: %s", err)), nil, nil
}

diff, format, isFallback, err := SemanticDiff(baseContent, headContent, path)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("failed to compute diff: %s", err)), nil, nil
}

var header string
if isFallback {
header = fmt.Sprintf("Format: %s (unified diff — no semantic diff available for this format)", format)
} else {
header = fmt.Sprintf("Format: %s (semantic diff)", format)
}

result := fmt.Sprintf("%s\n\n%s", header, diff)

return utils.NewToolResultText(result), nil, nil
},
)
st.FeatureFlagEnable = "semantic_diff"
return st
}

// fetchFileContent retrieves the raw content of a file at a given ref.
func fetchFileContent(ctx context.Context, client *raw.Client, owner, repo, path, ref string) ([]byte, error) {
resp, err := client.GetRawContent(ctx, owner, repo, path, &raw.ContentOpts{Ref: ref})
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("file %q not found at ref %q", path, ref)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d for %q at ref %q", resp.StatusCode, path, ref)
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}

return body, nil
}
202 changes: 202 additions & 0 deletions pkg/github/compare_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
package github

import (
"context"
"net/http"
"net/url"
"strings"
"testing"

"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v82/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func Test_CompareFileContents(t *testing.T) {
// Verify tool definition and snapshot
toolDef := CompareFileContents(translations.NullTranslationHelper)
assert.Equal(t, "compare_file_contents", toolDef.Tool.Name)
assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint)
assert.Equal(t, "semantic_diff", toolDef.FeatureFlagEnable)
require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool))

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
expectContains []string
}{
{
name: "successful JSON semantic diff",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"": func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case containsRef(path, "abc123"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name":"Bob","age":30}`))
case containsRef(path, "def456"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"name":"Bobby","age":30}`))
default:
w.WriteHeader(http.StatusNotFound)
}
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"path": "config.json",
"base": "abc123",
"head": "def456",
},
expectError: false,
expectContains: []string{
"Format: json (semantic diff)",
`name: "Bob" → "Bobby"`,
},
},
{
name: "successful YAML semantic diff",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"": func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case containsRef(path, "v1.0"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("name: Alice\nage: 30\n"))
case containsRef(path, "v2.0"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("name: Alice\nage: 31\n"))
default:
w.WriteHeader(http.StatusNotFound)
}
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"path": "config.yaml",
"base": "v1.0",
"head": "v2.0",
},
expectError: false,
expectContains: []string{
"Format: yaml (semantic diff)",
"age: 30 → 31",
},
},
{
name: "fallback to unified diff for txt",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"": func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case containsRef(path, "old"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("line1\nline2\nline3\n"))
case containsRef(path, "new"):
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("line1\nmodified\nline3\n"))
default:
w.WriteHeader(http.StatusNotFound)
}
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"path": "readme.txt",
"base": "old",
"head": "new",
},
expectError: false,
expectContains: []string{
"unified diff",
"-line2",
"+modified",
},
},
{
name: "base file not found",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
"": func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
},
}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"path": "missing.json",
"base": "abc123",
"head": "def456",
},
expectError: true,
expectedErrMsg: "failed to fetch base file",
},
{
name: "missing required param owner",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"repo": "repo",
"path": "file.json",
"base": "abc",
"head": "def",
},
expectError: true,
expectedErrMsg: "missing required parameter: owner",
},
{
name: "missing required param base",
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
requestArgs: map[string]any{
"owner": "owner",
"repo": "repo",
"path": "file.json",
"head": "def",
},
expectError: true,
expectedErrMsg: "missing required parameter: base",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := github.NewClient(tc.mockedClient)
mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"})
deps := BaseDeps{
Client: client,
RawClient: mockRawClient,
}
handler := toolDef.Handler(deps)

request := createMCPRequest(tc.requestArgs)
result, err := handler(ContextWithDeps(context.Background(), deps), &request)

if tc.expectError {
require.NoError(t, err)
textContent := getErrorResult(t, result)
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
return
}

require.NoError(t, err)
require.False(t, result.IsError)
textContent := getTextResult(t, result)

for _, expected := range tc.expectContains {
assert.Contains(t, textContent.Text, expected)
}
})
}
}

// containsRef checks if a URL path contains a specific ref segment.
func containsRef(path, ref string) bool {
return strings.Contains(path, "/"+ref+"/")
}
Comment on lines 199 to 202
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functions contains, searchString, and containsRef reinvent standard library functionality. The Go standard library provides strings.Contains which is widely used elsewhere in the codebase (seen in repositories_test.go, helper_test.go, etc.). Using standard library functions makes the code more maintainable and easier to understand.

Replace the custom contains and searchString functions with strings.Contains, and simplify containsRef to use it directly.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — replaced contains/searchString with strings.Contains. containsRef is kept as a thin wrapper since it adds the /ref/ segment formatting.

Loading
Loading