5683 lines
177 KiB
Go
5683 lines
177 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/github/github-mcp-server/internal/githubv4mock"
|
|
"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/github/github-mcp-server/pkg/utils"
|
|
"github.com/google/go-github/v89/github"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/shurcooL/githubv4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func Test_GetFileContents(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := GetFileContents(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
// GetFileContents is the FeatureFlagFieldsParam-enabled variant; it owns the
|
|
// _ff_<flag> snapshot. The canonical get_file_contents.snap is owned by
|
|
// LegacyGetFileContents (see Test_LegacyGetFileContents_Definition).
|
|
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
|
|
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
|
|
|
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
|
|
|
|
assert.Equal(t, "get_file_contents", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "path")
|
|
assert.Contains(t, schema.Properties, "ref")
|
|
assert.Contains(t, schema.Properties, "sha")
|
|
assert.Contains(t, schema.Properties, "fields")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
// Mock response for raw content
|
|
mockRawContent := []byte("# Test Repository\n\nThis is a test repository.")
|
|
|
|
// Setup mock directory content for success case
|
|
mockDirContent := []*github.RepositoryContent{
|
|
{
|
|
Type: github.Ptr("file"),
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Size: github.Ptr(42),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
|
|
},
|
|
{
|
|
Type: github.Ptr("dir"),
|
|
Name: github.Ptr("src"),
|
|
Path: github.Ptr("src"),
|
|
SHA: github.Ptr("def456"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedResult any
|
|
expectedErrMsg string
|
|
expectStatus int
|
|
expectedMsg string // optional: expected message text to verify in result
|
|
}{
|
|
{
|
|
name: "successful text content fetch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// Base64 encode the content as GitHub API does
|
|
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(mockRawContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "README.md",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/README.md",
|
|
Text: "# Test Repository\n\nThis is a test repository.",
|
|
MIMEType: "text/plain; charset=utf-8",
|
|
},
|
|
},
|
|
{
|
|
name: "successful binary file content fetch (PNG)",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// PNG magic bytes followed by some data
|
|
pngContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01")
|
|
encodedContent := base64.StdEncoding.EncodeToString(pngContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("test.png"),
|
|
Path: github.Ptr("test.png"),
|
|
SHA: github.Ptr("def456"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(pngContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "test.png",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/test.png",
|
|
Blob: []byte(base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"))),
|
|
MIMEType: "image/png",
|
|
},
|
|
},
|
|
{
|
|
name: "successful binary file content fetch (PDF)",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// PDF magic bytes
|
|
pdfContent := []byte("%PDF-1.4 fake pdf content")
|
|
encodedContent := base64.StdEncoding.EncodeToString(pdfContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("document.pdf"),
|
|
Path: github.Ptr("document.pdf"),
|
|
SHA: github.Ptr("pdf123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(pdfContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "document.pdf",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/document.pdf",
|
|
Blob: []byte(base64.StdEncoding.EncodeToString([]byte("%PDF-1.4 fake pdf content"))),
|
|
MIMEType: "application/pdf",
|
|
},
|
|
},
|
|
{
|
|
name: "successful directory content fetch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen(
|
|
mockResponse(t, http.StatusOK, mockDirContent),
|
|
),
|
|
GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen(
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "src/",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockDirContent,
|
|
},
|
|
{
|
|
name: "successful text content fetch with leading slash in path",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// Base64 encode the content as GitHub API does
|
|
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(mockRawContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "/README.md",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/README.md",
|
|
Text: "# Test Repository\n\nThis is a test repository.",
|
|
MIMEType: "text/plain; charset=utf-8",
|
|
},
|
|
},
|
|
{
|
|
name: "successful text content fetch with note when ref falls back to default branch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"develop\"}"),
|
|
GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.ReplaceAll(r.URL.Path, "%2F", "/")
|
|
switch {
|
|
case strings.Contains(path, "heads/main"):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
case strings.Contains(path, "heads/develop"):
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}
|
|
},
|
|
"GET /repos/{owner}/{repo}/git/refs/{ref}": func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.ReplaceAll(r.URL.Path, "%2F", "/")
|
|
switch {
|
|
case strings.Contains(path, "heads/main"):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
case strings.Contains(path, "heads/develop"):
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}
|
|
},
|
|
"GET /repos/{owner}/{repo}/git/refs/{ref:.*}": func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.ReplaceAll(r.URL.Path, "%2F", "/")
|
|
switch {
|
|
case strings.Contains(path, "heads/main"):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
case strings.Contains(path, "heads/develop"):
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}
|
|
},
|
|
"GET /repos/owner/repo/git/ref/heads/main": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
},
|
|
"GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456abc123def456abc123def456abc1", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456abc123def456abc123def456abc1"}}`))
|
|
},
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// Base64 encode the content as GitHub API does
|
|
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(mockRawContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "README.md",
|
|
"ref": "main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/sha/abc123def456abc123def456abc123def456abc1/contents/README.md",
|
|
Text: "# Test Repository\n\nThis is a test repository.",
|
|
MIMEType: "text/plain; charset=utf-8",
|
|
},
|
|
expectedMsg: " Note: the provided ref 'main' does not exist, default branch 'refs/heads/develop' was used instead.",
|
|
},
|
|
{
|
|
name: "large file returns ResourceLink",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
// File larger than 1MB - Contents API returns metadata but no content
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("large-file.bin"),
|
|
Path: github.Ptr("large-file.bin"),
|
|
SHA: github.Ptr("largesha123"),
|
|
Type: github.Ptr("file"),
|
|
Size: github.Ptr(2 * 1024 * 1024), // 2MB
|
|
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "large-file.bin",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: &mcp.ResourceLink{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/large-file.bin",
|
|
Name: "large-file.bin",
|
|
Title: "File: large-file.bin",
|
|
},
|
|
},
|
|
{
|
|
name: "successful empty file content fetch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr(".gitkeep"),
|
|
Path: github.Ptr(".gitkeep"),
|
|
SHA: github.Ptr("empty123"),
|
|
Type: github.Ptr("file"),
|
|
Content: nil,
|
|
Size: github.Ptr(0),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": ".gitkeep",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mcp.ResourceContents{
|
|
URI: "repo://owner/repo/refs/heads/main/contents/.gitkeep",
|
|
Text: "",
|
|
MIMEType: "text/plain",
|
|
},
|
|
expectedMsg: "successfully downloaded empty file",
|
|
},
|
|
{
|
|
name: "content fetch fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
},
|
|
GetRawReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "nonexistent.md",
|
|
"ref": "refs/heads/main",
|
|
},
|
|
expectError: false,
|
|
expectedResult: utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"})
|
|
require.NoError(t, err)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
RawClient: mockRawClient,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
textContent := getErrorResult(t, result)
|
|
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
// Use the correct result helper based on the expected type
|
|
switch expected := tc.expectedResult.(type) {
|
|
case mcp.ResourceContents:
|
|
// Handle both text and blob resources
|
|
resource := getResourceResult(t, result)
|
|
assert.Equal(t, expected, *resource)
|
|
|
|
// If expectedMsg is set, verify the message text
|
|
if tc.expectedMsg != "" {
|
|
require.Len(t, result.Content, 2)
|
|
textContent, ok := result.Content[0].(*mcp.TextContent)
|
|
require.True(t, ok, "expected Content[0] to be TextContent")
|
|
assert.Contains(t, textContent.Text, tc.expectedMsg)
|
|
}
|
|
case []*github.RepositoryContent:
|
|
// Directory content fetch returns a text result (JSON array)
|
|
textContent := getTextResult(t, result)
|
|
var returnedContents []*github.RepositoryContent
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedContents)
|
|
require.NoError(t, err, "Failed to unmarshal directory content result: %v", textContent.Text)
|
|
assert.Len(t, returnedContents, len(expected))
|
|
for i, content := range returnedContents {
|
|
assert.Equal(t, *expected[i].Name, *content.Name)
|
|
assert.Equal(t, *expected[i].Path, *content.Path)
|
|
assert.Equal(t, *expected[i].Type, *content.Type)
|
|
}
|
|
case *mcp.ResourceLink:
|
|
// Large file returns a ResourceLink
|
|
require.Len(t, result.Content, 2)
|
|
resourceLink, ok := result.Content[1].(*mcp.ResourceLink)
|
|
require.True(t, ok, "expected Content[1] to be ResourceLink")
|
|
assert.Equal(t, expected.URI, resourceLink.URI)
|
|
assert.Equal(t, expected.Name, resourceLink.Name)
|
|
assert.Equal(t, expected.Title, resourceLink.Title)
|
|
case mcp.TextContent:
|
|
textContent := getErrorResult(t, result)
|
|
require.Equal(t, textContent, expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) {
|
|
mockDirContent := []*github.RepositoryContent{
|
|
{
|
|
Type: github.Ptr("file"),
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Size: github.Ptr(42),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
|
|
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
|
|
},
|
|
{
|
|
Type: github.Ptr("dir"),
|
|
Name: github.Ptr("src"),
|
|
Path: github.Ptr("src"),
|
|
SHA: github.Ptr("def456"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"),
|
|
},
|
|
}
|
|
|
|
serverTool := GetFileContents(translations.NullTranslationHelper)
|
|
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen(
|
|
mockResponse(t, http.StatusOK, mockDirContent),
|
|
),
|
|
GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen(
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
}))
|
|
deps := BaseDeps{Client: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "src/",
|
|
"fields": []any{"name", "type"},
|
|
})
|
|
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Each directory entry is reduced to the requested fields only; heavier
|
|
// fields such as html_url and download_url are dropped.
|
|
var returned []map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned))
|
|
require.Len(t, returned, len(mockDirContent))
|
|
for _, entry := range returned {
|
|
require.Len(t, entry, 2)
|
|
assert.Contains(t, entry, "name")
|
|
assert.Contains(t, entry, "type")
|
|
}
|
|
|
|
assert.NotContains(t, textContent.Text, "html_url")
|
|
assert.NotContains(t, textContent.Text, "download_url")
|
|
}
|
|
|
|
func Test_LegacyGetFileContents_Definition(t *testing.T) {
|
|
serverTool := LegacyGetFileContents(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
// LegacyGetFileContents is the FeatureFlagFieldsParam-disabled variant and
|
|
// owns the canonical get_file_contents.snap (no `fields`).
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable)
|
|
|
|
assert.Equal(t, "get_file_contents", tool.Name)
|
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
|
|
assert.NotContains(t, schema.Properties, "fields")
|
|
}
|
|
|
|
func Test_GetFileContents_DirectoryFieldsTelemetry(t *testing.T) {
|
|
mockDirContent := []*github.RepositoryContent{
|
|
{
|
|
Type: github.Ptr("file"),
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Size: github.Ptr(42),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"),
|
|
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"),
|
|
},
|
|
}
|
|
|
|
serverTool := GetFileContents(translations.NullTranslationHelper)
|
|
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"),
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen(
|
|
mockResponse(t, http.StatusOK, mockDirContent),
|
|
),
|
|
GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen(
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
}))
|
|
deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client})
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "src/",
|
|
"fields": []any{"name", "type"},
|
|
})
|
|
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
call, ok := rec.increment(metricFieldsToolCall)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "get_file_contents", call.tags["tool"])
|
|
assert.Equal(t, "true", call.tags["filtered"])
|
|
|
|
full, ok := rec.counter(metricFieldsBytesFull)
|
|
require.True(t, ok)
|
|
sent, ok := rec.counter(metricFieldsBytesSent)
|
|
require.True(t, ok)
|
|
assert.Greater(t, full.value, sent.value, "filtering should remove bytes")
|
|
}
|
|
|
|
func Test_GetFileContents_IFC_InsidersMode(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetFileContents(translations.NullTranslationHelper)
|
|
|
|
mockRawContent := []byte("hello")
|
|
|
|
makeMockClient := func(isPrivate bool) *http.Client {
|
|
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
|
|
"name": "repo",
|
|
"default_branch": "main",
|
|
"private": isPrivate,
|
|
}),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(mockRawContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
})
|
|
}
|
|
|
|
reqParams := map[string]any{
|
|
"owner": "octocat",
|
|
"repo": "repo",
|
|
"path": "README.md",
|
|
"ref": "refs/heads/main",
|
|
}
|
|
|
|
t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false)),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled")
|
|
})
|
|
|
|
t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false)),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
require.NotNil(t, result.Meta)
|
|
ifcLabel, ok := result.Meta["ifc"]
|
|
require.True(t, ok, "result meta should contain ifc key")
|
|
|
|
ifcJSON, err := json.Marshal(ifcLabel)
|
|
require.NoError(t, err)
|
|
var ifcMap map[string]any
|
|
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
|
|
|
|
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
|
assert.Equal(t, "public", ifcMap["confidentiality"])
|
|
})
|
|
|
|
t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(true)),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
require.NotNil(t, result.Meta)
|
|
ifcLabel, ok := result.Meta["ifc"]
|
|
require.True(t, ok, "result meta should contain ifc key")
|
|
|
|
ifcJSON, err := json.Marshal(ifcLabel)
|
|
require.NoError(t, err)
|
|
var ifcMap map[string]any
|
|
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
|
|
|
|
assert.Equal(t, "trusted", ifcMap["integrity"])
|
|
assert.Equal(t, "private", ifcMap["confidentiality"])
|
|
})
|
|
|
|
t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) {
|
|
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"),
|
|
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
|
|
fileContent := &github.RepositoryContent{
|
|
Name: github.Ptr("README.md"),
|
|
Path: github.Ptr("README.md"),
|
|
SHA: github.Ptr("abc123"),
|
|
Type: github.Ptr("file"),
|
|
Content: github.Ptr(encodedContent),
|
|
Size: github.Ptr(len(mockRawContent)),
|
|
Encoding: github.Ptr("base64"),
|
|
}
|
|
contentBytes, _ := json.Marshal(fileContent)
|
|
_, _ = w.Write(contentBytes)
|
|
},
|
|
})
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, mockedClient),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
|
|
|
|
if result.Meta != nil {
|
|
_, hasIFC := result.Meta["ifc"]
|
|
assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
|
|
}
|
|
})
|
|
}
|
|
|
|
// Test_GetCommit_IFC_FeatureFlag verifies that the IFC security label is only
|
|
// attached to get_commit results when the ifc_labels feature flag is enabled,
|
|
// and that the label content matches the commit-contents rule (untrusted on
|
|
// public repos, trusted on private). It also confirms the label is omitted
|
|
// when the repository visibility lookup fails, so the result is never
|
|
// misclassified. get_commit is representative of every tool wired through the
|
|
// shared attachRepoVisibilityIFCLabel helper.
|
|
func Test_GetCommit_IFC_FeatureFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetCommit(translations.NullTranslationHelper)
|
|
|
|
mockCommit := &github.RepositoryCommit{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Commit: &github.Commit{Message: github.Ptr("First commit")},
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
|
|
}
|
|
|
|
makeMockClient := func(isPrivate bool) *http.Client {
|
|
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
|
|
"name": "repo",
|
|
"private": isPrivate,
|
|
}),
|
|
})
|
|
}
|
|
|
|
reqParams := map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"sha": "abc123def456",
|
|
}
|
|
|
|
t.Run("feature flag disabled omits ifc label from result meta", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false)),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled")
|
|
})
|
|
|
|
t.Run("feature flag enabled on public repo emits public untrusted label", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false)),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
require.NotNil(t, result.Meta)
|
|
ifcLabel, ok := result.Meta["ifc"]
|
|
require.True(t, ok, "result meta should contain ifc key")
|
|
|
|
ifcJSON, err := json.Marshal(ifcLabel)
|
|
require.NoError(t, err)
|
|
var ifcMap map[string]any
|
|
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
|
|
|
|
assert.Equal(t, "untrusted", ifcMap["integrity"])
|
|
assert.Equal(t, "public", ifcMap["confidentiality"])
|
|
})
|
|
|
|
t.Run("feature flag enabled on private repo emits private trusted label", func(t *testing.T) {
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(true)),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
require.NotNil(t, result.Meta)
|
|
ifcLabel, ok := result.Meta["ifc"]
|
|
require.True(t, ok, "result meta should contain ifc key")
|
|
|
|
ifcJSON, err := json.Marshal(ifcLabel)
|
|
require.NoError(t, err)
|
|
var ifcMap map[string]any
|
|
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
|
|
|
|
assert.Equal(t, "trusted", ifcMap["integrity"])
|
|
assert.Equal(t, "private", ifcMap["confidentiality"])
|
|
})
|
|
|
|
t.Run("feature flag enabled skips ifc label when visibility lookup fails", func(t *testing.T) {
|
|
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"),
|
|
})
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, mockedClient),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
|
|
|
|
if result.Meta != nil {
|
|
_, hasIFC := result.Meta["ifc"]
|
|
assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
|
|
}
|
|
})
|
|
}
|
|
|
|
func Test_ForkRepository(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ForkRepository(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, "fork_repository", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "organization")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
// Setup mock forked repo for success case
|
|
mockForkedRepo := &github.Repository{
|
|
ID: github.Ptr(int64(123456)),
|
|
Name: github.Ptr("repo"),
|
|
FullName: github.Ptr("new-owner/repo"),
|
|
Owner: &github.User{
|
|
Login: github.Ptr("new-owner"),
|
|
},
|
|
HTMLURL: github.Ptr("https://github.com/new-owner/repo"),
|
|
DefaultBranch: github.Ptr("main"),
|
|
Fork: github.Ptr(true),
|
|
ForksCount: github.Ptr(0),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedRepo *github.Repository
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful repository fork",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PostReposForksByOwnerByRepo: mockResponse(t, http.StatusAccepted, mockForkedRepo),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false,
|
|
expectedRepo: mockForkedRepo,
|
|
},
|
|
{
|
|
name: "repository fork fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PostReposForksByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"message": "Forbidden"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to fork repository",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
assert.Contains(t, textContent.Text, "Fork is in progress")
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_CreateBranch(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := CreateBranch(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, "create_branch", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "branch")
|
|
assert.Contains(t, schema.Properties, "from_branch")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "branch"})
|
|
|
|
// Setup mock repository for default branch test
|
|
mockRepo := &github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
}
|
|
|
|
// Setup mock reference for from_branch tests
|
|
mockSourceRef := &github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("abc123def456"),
|
|
},
|
|
}
|
|
|
|
// Setup mock created reference
|
|
mockCreatedRef := &github.Reference{
|
|
Ref: github.Ptr("refs/heads/new-feature"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("abc123def456"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedRef *github.Reference
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful branch creation with from_branch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef),
|
|
"GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef),
|
|
PostReposGitRefsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockCreatedRef),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "new-feature",
|
|
"from_branch": "main",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockCreatedRef,
|
|
},
|
|
{
|
|
name: "successful branch creation with default branch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo),
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef),
|
|
"GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef),
|
|
PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]any{
|
|
"ref": "refs/heads/new-feature",
|
|
"sha": "abc123def456",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockCreatedRef),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "new-feature",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockCreatedRef,
|
|
},
|
|
{
|
|
name: "fail to get repository",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Repository not found"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "nonexistent-repo",
|
|
"branch": "new-feature",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get repository",
|
|
},
|
|
{
|
|
name: "fail to get reference",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Reference not found"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "new-feature",
|
|
"from_branch": "nonexistent-branch",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get reference",
|
|
},
|
|
{
|
|
name: "fail to create branch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef),
|
|
"GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef),
|
|
PostReposGitRefsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Reference already exists"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "existing-branch",
|
|
"from_branch": "main",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to create branch",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedRef github.Reference
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRef)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref)
|
|
assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetCommit(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := GetCommit(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, "get_commit", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "sha")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "sha"})
|
|
|
|
mockCommit := &github.RepositoryCommit{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Commit: &github.Commit{
|
|
Message: github.Ptr("First commit"),
|
|
Author: &github.CommitAuthor{
|
|
Name: github.Ptr("Test User"),
|
|
Email: github.Ptr("test@example.com"),
|
|
Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)},
|
|
},
|
|
},
|
|
Author: &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
},
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
|
|
Stats: &github.CommitStats{
|
|
Additions: github.Ptr(10),
|
|
Deletions: github.Ptr(2),
|
|
Total: github.Ptr(12),
|
|
},
|
|
Files: []*github.CommitFile{
|
|
{
|
|
Filename: github.Ptr("file1.go"),
|
|
Status: github.Ptr("modified"),
|
|
Additions: github.Ptr(10),
|
|
Deletions: github.Ptr(2),
|
|
Changes: github.Ptr(12),
|
|
Patch: github.Ptr("@@ -1,2 +1,10 @@"),
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedCommit *github.RepositoryCommit
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful commit fetch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"sha": "abc123def456",
|
|
},
|
|
expectError: false,
|
|
expectedCommit: mockCommit,
|
|
},
|
|
{
|
|
name: "commit fetch fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"sha": "nonexistent-sha",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get commit",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedCommit github.RepositoryCommit
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedCommit)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, *tc.expectedCommit.SHA, *returnedCommit.SHA)
|
|
assert.Equal(t, *tc.expectedCommit.Commit.Message, *returnedCommit.Commit.Message)
|
|
assert.Equal(t, *tc.expectedCommit.Author.Login, *returnedCommit.Author.Login)
|
|
assert.Equal(t, *tc.expectedCommit.HTMLURL, *returnedCommit.HTMLURL)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetCommit_Detail(t *testing.T) {
|
|
mockCommit := &github.RepositoryCommit{
|
|
SHA: github.Ptr("abc123def456"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
|
|
Commit: &github.Commit{
|
|
Message: github.Ptr("First commit"),
|
|
},
|
|
Stats: &github.CommitStats{
|
|
Additions: github.Ptr(10),
|
|
Deletions: github.Ptr(2),
|
|
Total: github.Ptr(12),
|
|
},
|
|
Files: []*github.CommitFile{
|
|
{
|
|
Filename: github.Ptr("file1.go"),
|
|
Status: github.Ptr("modified"),
|
|
Additions: github.Ptr(10),
|
|
Deletions: github.Ptr(2),
|
|
Changes: github.Ptr(12),
|
|
Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"),
|
|
},
|
|
},
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
expectFiles bool
|
|
expectStats bool
|
|
expectPatch bool
|
|
expectError string
|
|
}{
|
|
{
|
|
name: "default returns stats",
|
|
args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456"},
|
|
expectFiles: true,
|
|
expectStats: true,
|
|
expectPatch: false,
|
|
},
|
|
{
|
|
name: "detail=none omits stats and files",
|
|
args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "none"},
|
|
expectFiles: false,
|
|
expectStats: false,
|
|
expectPatch: false,
|
|
},
|
|
{
|
|
name: "detail=stats returns metadata without patch",
|
|
args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "stats"},
|
|
expectFiles: true,
|
|
expectStats: true,
|
|
expectPatch: false,
|
|
},
|
|
{
|
|
name: "detail=full_patch includes patch text",
|
|
args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "full_patch"},
|
|
expectFiles: true,
|
|
expectStats: true,
|
|
expectPatch: true,
|
|
},
|
|
{
|
|
name: "invalid detail value is rejected",
|
|
args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "everything"},
|
|
expectError: `invalid detail "everything"`,
|
|
},
|
|
}
|
|
|
|
serverTool := GetCommit(translations.NullTranslationHelper)
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit),
|
|
})
|
|
client := mustNewGHClient(t, mockedClient)
|
|
deps := BaseDeps{Client: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.args)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectError != "" {
|
|
require.True(t, result.IsError)
|
|
assert.Contains(t, getErrorResult(t, result).Text, tc.expectError)
|
|
return
|
|
}
|
|
require.False(t, result.IsError)
|
|
|
|
var returned MinimalCommit
|
|
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned))
|
|
|
|
if tc.expectStats {
|
|
require.NotNil(t, returned.Stats)
|
|
assert.Equal(t, 12, returned.Stats.Total)
|
|
} else {
|
|
assert.Nil(t, returned.Stats)
|
|
}
|
|
|
|
if tc.expectFiles {
|
|
require.Len(t, returned.Files, 1)
|
|
assert.Equal(t, "file1.go", returned.Files[0].Filename)
|
|
if tc.expectPatch {
|
|
assert.Equal(t, "@@ -1,2 +1,10 @@\n+new line", returned.Files[0].Patch)
|
|
} else {
|
|
assert.Empty(t, returned.Files[0].Patch)
|
|
}
|
|
} else {
|
|
assert.Empty(t, returned.Files)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ListCommits(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ListCommits(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
// ListCommits is the FeatureFlagFieldsParam-enabled variant; it owns the
|
|
// _ff_<flag> snapshot. The canonical list_commits.snap is owned by
|
|
// LegacyListCommits (see Test_LegacyListCommits_Definition).
|
|
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
|
|
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
|
|
|
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
|
|
|
|
assert.Equal(t, "list_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, "sha")
|
|
assert.Contains(t, schema.Properties, "author")
|
|
assert.Contains(t, schema.Properties, "path")
|
|
assert.Contains(t, schema.Properties, "since")
|
|
assert.Contains(t, schema.Properties, "until")
|
|
assert.Contains(t, schema.Properties, "page")
|
|
assert.Contains(t, schema.Properties, "perPage")
|
|
assert.Contains(t, schema.Properties, "fields")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
// Setup mock commits for success case
|
|
mockCommits := []*github.RepositoryCommit{
|
|
{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Commit: &github.Commit{
|
|
Message: github.Ptr("First commit"),
|
|
Author: &github.CommitAuthor{
|
|
Name: github.Ptr("Test User"),
|
|
Email: github.Ptr("test@example.com"),
|
|
Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)},
|
|
},
|
|
},
|
|
Author: &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
ID: github.Ptr(int64(12345)),
|
|
HTMLURL: github.Ptr("https://github.com/testuser"),
|
|
AvatarURL: github.Ptr("https://github.com/testuser.png"),
|
|
},
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"),
|
|
Stats: &github.CommitStats{
|
|
Additions: github.Ptr(10),
|
|
Deletions: github.Ptr(5),
|
|
Total: github.Ptr(15),
|
|
},
|
|
Files: []*github.CommitFile{
|
|
{
|
|
Filename: github.Ptr("src/main.go"),
|
|
Status: github.Ptr("modified"),
|
|
Additions: github.Ptr(8),
|
|
Deletions: github.Ptr(3),
|
|
Changes: github.Ptr(11),
|
|
},
|
|
{
|
|
Filename: github.Ptr("README.md"),
|
|
Status: github.Ptr("added"),
|
|
Additions: github.Ptr(2),
|
|
Deletions: github.Ptr(2),
|
|
Changes: github.Ptr(4),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
SHA: github.Ptr("def456abc789"),
|
|
Commit: &github.Commit{
|
|
Message: github.Ptr("Second commit"),
|
|
Author: &github.CommitAuthor{
|
|
Name: github.Ptr("Another User"),
|
|
Email: github.Ptr("another@example.com"),
|
|
Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)},
|
|
},
|
|
},
|
|
Author: &github.User{
|
|
Login: github.Ptr("anotheruser"),
|
|
ID: github.Ptr(int64(67890)),
|
|
HTMLURL: github.Ptr("https://github.com/anotheruser"),
|
|
AvatarURL: github.Ptr("https://github.com/anotheruser.png"),
|
|
},
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
|
|
Stats: &github.CommitStats{
|
|
Additions: github.Ptr(20),
|
|
Deletions: github.Ptr(10),
|
|
Total: github.Ptr(30),
|
|
},
|
|
Files: []*github.CommitFile{
|
|
{
|
|
Filename: github.Ptr("src/utils.go"),
|
|
Status: github.Ptr("added"),
|
|
Additions: github.Ptr(20),
|
|
Deletions: github.Ptr(10),
|
|
Changes: github.Ptr(30),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedCommits []*github.RepositoryCommit
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful commits fetch with default params",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockCommits),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "successful commits fetch with branch",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{
|
|
"author": "username",
|
|
"sha": "main",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockCommits),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"sha": "main",
|
|
"author": "username",
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "successful commits fetch with path filter",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{
|
|
"path": "src/main.go",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockCommits),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "src/main.go",
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "successful commits fetch with since and until",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{
|
|
"since": "2023-01-01T00:00:00Z",
|
|
"until": "2023-12-31T23:59:59Z",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockCommits),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"since": "2023-01-01T00:00:00Z",
|
|
"until": "2023-12-31T23:59:59Z",
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "successful commits fetch with path, since, and author",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{
|
|
"path": "projects/plugins/boost",
|
|
"since": "2023-06-15T00:00:00Z",
|
|
"author": "username",
|
|
"page": "1",
|
|
"per_page": "30",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockCommits),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "projects/plugins/boost",
|
|
"since": "2023-06-15T00:00:00Z",
|
|
"author": "username",
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "invalid since timestamp returns error",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"since": "not-a-date",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "invalid since timestamp",
|
|
},
|
|
{
|
|
name: "successful commits fetch with pagination",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{
|
|
"page": "2",
|
|
"per_page": "10",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockCommits),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"page": float64(2),
|
|
"perPage": float64(10),
|
|
},
|
|
expectError: false,
|
|
expectedCommits: mockCommits,
|
|
},
|
|
{
|
|
name: "commits fetch fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "nonexistent-repo",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to list commits",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedCommits []MinimalCommit
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedCommits)
|
|
require.NoError(t, err)
|
|
assert.Len(t, returnedCommits, len(tc.expectedCommits))
|
|
for i, commit := range returnedCommits {
|
|
assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA)
|
|
assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL)
|
|
if tc.expectedCommits[i].Commit != nil {
|
|
assert.Equal(t, tc.expectedCommits[i].Commit.GetMessage(), commit.Commit.Message)
|
|
}
|
|
if tc.expectedCommits[i].Author != nil {
|
|
assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login)
|
|
}
|
|
|
|
// Files and stats are never included in list_commits
|
|
assert.Nil(t, commit.Files)
|
|
assert.Nil(t, commit.Stats)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_CreateOrUpdateFile(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := CreateOrUpdateFile(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, "create_or_update_file", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "path")
|
|
assert.Contains(t, schema.Properties, "content")
|
|
assert.Contains(t, schema.Properties, "message")
|
|
assert.Contains(t, schema.Properties, "branch")
|
|
assert.Contains(t, schema.Properties, "sha")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path", "content", "message", "branch"})
|
|
|
|
// Setup mock file content response
|
|
mockFileResponse := &github.RepositoryContentResponse{
|
|
Content: &github.RepositoryContent{
|
|
Name: github.Ptr("example.md"),
|
|
Path: github.Ptr("docs/example.md"),
|
|
SHA: github.Ptr("abc123def456"),
|
|
Size: github.Ptr(42),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"),
|
|
DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"),
|
|
},
|
|
Commit: github.Commit{
|
|
SHA: github.Ptr("def456abc789"),
|
|
Message: github.Ptr("Add example file"),
|
|
Author: &github.CommitAuthor{
|
|
Name: github.Ptr("Test User"),
|
|
Email: github.Ptr("test@example.com"),
|
|
Date: &github.Timestamp{Time: time.Now()},
|
|
},
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedContent *github.RepositoryContentResponse
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful file creation",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{
|
|
"message": "Add example file",
|
|
"content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content
|
|
"branch": "main",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{
|
|
"message": "Add example file",
|
|
"content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content
|
|
"branch": "main",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# Example\n\nThis is an example file.",
|
|
"message": "Add example file",
|
|
"branch": "main",
|
|
},
|
|
expectError: false,
|
|
expectedContent: mockFileResponse,
|
|
},
|
|
{
|
|
name: "successful file update with SHA",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{
|
|
"message": "Update example file",
|
|
"content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{
|
|
"message": "Update example file",
|
|
"content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# Updated Example\n\nThis file has been updated.",
|
|
"message": "Update example file",
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
},
|
|
expectError: false,
|
|
expectedContent: mockFileResponse,
|
|
},
|
|
{
|
|
name: "file creation fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PutReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
|
|
},
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Invalid request"}`))
|
|
},
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "#Invalid Content",
|
|
"message": "Invalid request",
|
|
"branch": "nonexistent-branch",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to create/update file",
|
|
},
|
|
{
|
|
name: "sha validation - current sha matches",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("abc123def456"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{
|
|
"message": "Update example file",
|
|
"content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==",
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{
|
|
"message": "Update example file",
|
|
"content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==",
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockFileResponse),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# Updated Example\n\nThis file has been updated.",
|
|
"message": "Update example file",
|
|
"branch": "main",
|
|
"sha": "abc123def456",
|
|
},
|
|
expectError: false,
|
|
expectedContent: mockFileResponse,
|
|
},
|
|
{
|
|
name: "sha validation - stale sha detected",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("newsha999888"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("newsha999888"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# Updated Example\n\nThis file has been updated.",
|
|
"message": "Update example file",
|
|
"branch": "main",
|
|
"sha": "oldsha123456",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is newsha999888",
|
|
},
|
|
{
|
|
name: "sha validation - file doesn't exist (404), proceed with create",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{
|
|
"message": "Create new file",
|
|
"content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==",
|
|
"branch": "main",
|
|
"sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockFileResponse),
|
|
),
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{
|
|
"message": "Create new file",
|
|
"content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==",
|
|
"branch": "main",
|
|
"sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockFileResponse),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# New File\n\nThis is a new file.",
|
|
"message": "Create new file",
|
|
"branch": "main",
|
|
"sha": "ignoredsha",
|
|
},
|
|
expectError: false,
|
|
expectedContent: mockFileResponse,
|
|
},
|
|
{
|
|
name: "no sha provided - file exists, rejects update",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("existing123"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{
|
|
SHA: github.Ptr("existing123"),
|
|
Type: github.Ptr("file"),
|
|
}),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# Updated\n\nUpdated without SHA.",
|
|
"message": "Update without SHA",
|
|
"branch": "main",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "File already exists at docs/example.md",
|
|
},
|
|
{
|
|
name: "no sha provided - file doesn't exist, no warning",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
"GET /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
"GET /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
},
|
|
PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{
|
|
"message": "Create new file",
|
|
"content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==",
|
|
"branch": "main",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockFileResponse),
|
|
),
|
|
"PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]any{
|
|
"message": "Create new file",
|
|
"content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==",
|
|
"branch": "main",
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockFileResponse),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"content": "# New File\n\nCreated without SHA",
|
|
"message": "Create new file",
|
|
"branch": "main",
|
|
},
|
|
expectError: false,
|
|
expectedContent: mockFileResponse,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// If expectedErrMsg is set (but expectError is false), this is a warning case
|
|
if tc.expectedErrMsg != "" {
|
|
assert.Contains(t, textContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedContent MinimalFileContentResponse
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedContent)
|
|
require.NoError(t, err)
|
|
|
|
// Verify content
|
|
assert.Equal(t, tc.expectedContent.Content.GetName(), returnedContent.Content.Name)
|
|
assert.Equal(t, tc.expectedContent.Content.GetPath(), returnedContent.Content.Path)
|
|
assert.Equal(t, tc.expectedContent.Content.GetSHA(), returnedContent.Content.SHA)
|
|
assert.Equal(t, tc.expectedContent.Content.GetSize(), returnedContent.Content.Size)
|
|
assert.Equal(t, tc.expectedContent.Content.GetHTMLURL(), returnedContent.Content.HTMLURL)
|
|
|
|
// Verify commit
|
|
assert.Equal(t, tc.expectedContent.Commit.GetSHA(), returnedContent.Commit.SHA)
|
|
assert.Equal(t, tc.expectedContent.Commit.GetMessage(), returnedContent.Commit.Message)
|
|
assert.Equal(t, tc.expectedContent.Commit.GetHTMLURL(), returnedContent.Commit.HTMLURL)
|
|
|
|
// Verify commit author
|
|
require.NotNil(t, returnedContent.Commit.Author)
|
|
assert.Equal(t, tc.expectedContent.Commit.Author.GetName(), returnedContent.Commit.Author.Name)
|
|
assert.Equal(t, tc.expectedContent.Commit.Author.GetEmail(), returnedContent.Commit.Author.Email)
|
|
assert.NotEmpty(t, returnedContent.Commit.Author.Date)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_CreateRepository(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := CreateRepository(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, "create_repository", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "name")
|
|
assert.Contains(t, schema.Properties, "description")
|
|
assert.Contains(t, schema.Properties, "organization")
|
|
assert.Contains(t, schema.Properties, "private")
|
|
assert.Contains(t, schema.Properties, "autoInit")
|
|
assert.ElementsMatch(t, schema.Required, []string{"name"})
|
|
|
|
// Setup mock repository response
|
|
mockRepo := &github.Repository{
|
|
Name: github.Ptr("test-repo"),
|
|
Description: github.Ptr("Test repository"),
|
|
Private: github.Ptr(true),
|
|
HTMLURL: github.Ptr("https://github.com/testuser/test-repo"),
|
|
CreatedAt: &github.Timestamp{Time: time.Now()},
|
|
Owner: &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedRepo *github.Repository
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful repository creation with all parameters",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
EndpointPattern("POST /user/repos"),
|
|
expectRequestBody(t, map[string]any{
|
|
"name": "test-repo",
|
|
"description": "Test repository",
|
|
"private": true,
|
|
"auto_init": true,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockRepo),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"name": "test-repo",
|
|
"description": "Test repository",
|
|
"private": true,
|
|
"autoInit": true,
|
|
},
|
|
expectError: false,
|
|
expectedRepo: mockRepo,
|
|
},
|
|
{
|
|
name: "successful repository creation in organization",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
EndpointPattern("POST /orgs/testorg/repos"),
|
|
expectRequestBody(t, map[string]any{
|
|
"name": "test-repo",
|
|
"description": "Test repository",
|
|
"private": false,
|
|
"auto_init": true,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockRepo),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"name": "test-repo",
|
|
"description": "Test repository",
|
|
"organization": "testorg",
|
|
"private": false,
|
|
"autoInit": true,
|
|
},
|
|
expectError: false,
|
|
expectedRepo: mockRepo,
|
|
},
|
|
{
|
|
name: "successful repository creation with minimal parameters defaults to private",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
EndpointPattern("POST /user/repos"),
|
|
expectRequestBody(t, map[string]any{
|
|
"name": "test-repo",
|
|
"auto_init": false,
|
|
"description": "",
|
|
"private": true,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockRepo),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"name": "test-repo",
|
|
},
|
|
expectError: false,
|
|
expectedRepo: mockRepo,
|
|
},
|
|
{
|
|
name: "successful public repository creation when private is explicitly false",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
EndpointPattern("POST /user/repos"),
|
|
expectRequestBody(t, map[string]any{
|
|
"name": "test-repo",
|
|
"auto_init": false,
|
|
"description": "",
|
|
"private": false,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockRepo),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"name": "test-repo",
|
|
"private": false,
|
|
},
|
|
expectError: false,
|
|
expectedRepo: mockRepo,
|
|
},
|
|
{
|
|
name: "repository creation fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
EndpointPattern("POST /user/repos"),
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnprocessableEntity)
|
|
_, _ = w.Write([]byte(`{"message": "Repository creation failed"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"name": "invalid-repo",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to create repository",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the minimal result
|
|
var returnedRepo MinimalResponse
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRepo)
|
|
assert.NoError(t, err)
|
|
|
|
// Verify repository details
|
|
assert.Equal(t, tc.expectedRepo.GetHTMLURL(), returnedRepo.URL)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_PushFiles(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := PushFiles(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, "push_files", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "branch")
|
|
assert.Contains(t, schema.Properties, "files")
|
|
assert.Contains(t, schema.Properties, "message")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "branch", "files", "message"})
|
|
|
|
// Setup mock objects
|
|
mockRef := &github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("abc123"),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"),
|
|
},
|
|
}
|
|
|
|
mockCommit := &github.Commit{
|
|
SHA: github.Ptr("abc123"),
|
|
Tree: &github.Tree{
|
|
SHA: github.Ptr("def456"),
|
|
},
|
|
}
|
|
|
|
mockTree := &github.Tree{
|
|
SHA: github.Ptr("ghi789"),
|
|
}
|
|
|
|
mockNewCommit := &github.Commit{
|
|
SHA: github.Ptr("jkl012"),
|
|
Message: github.Ptr("Update multiple files"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"),
|
|
}
|
|
|
|
mockUpdatedRef := &github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("jkl012"),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"),
|
|
},
|
|
}
|
|
|
|
// Define test cases
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedRef *github.Reference
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful push of multiple files",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Get commit
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
// Create tree
|
|
WithRequestMatchHandler(
|
|
PostReposGitTreesByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"base_tree": "def456",
|
|
"tree": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"content": "# Updated README\n\nThis is an updated README file.",
|
|
},
|
|
map[string]any{
|
|
"path": "docs/example.md",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"content": "# Example\n\nThis is an example file.",
|
|
},
|
|
},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockTree),
|
|
),
|
|
),
|
|
// Create commit
|
|
WithRequestMatchHandler(
|
|
PostReposGitCommitsByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"message": "Update multiple files",
|
|
"tree": "ghi789",
|
|
"parents": []any{"abc123"},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockNewCommit),
|
|
),
|
|
),
|
|
// Update reference
|
|
WithRequestMatchHandler(
|
|
PatchReposGitRefsByOwnerByRepoByRef,
|
|
expectRequestBody(t, map[string]any{
|
|
"sha": "jkl012",
|
|
"force": false,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockUpdatedRef),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# Updated README\n\nThis is an updated README file.",
|
|
},
|
|
map[string]any{
|
|
"path": "docs/example.md",
|
|
"content": "# Example\n\nThis is an example file.",
|
|
},
|
|
},
|
|
"message": "Update multiple files",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockUpdatedRef,
|
|
},
|
|
{
|
|
name: "fails when files parameter is invalid",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// No requests expected
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": "invalid-files-parameter", // Not an array
|
|
"message": "Update multiple files",
|
|
},
|
|
expectError: false, // This returns a tool error, not a Go error
|
|
expectedErrMsg: "files parameter must be an array",
|
|
},
|
|
{
|
|
name: "fails when files contains object without path",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Get commit
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"content": "# Missing path",
|
|
},
|
|
},
|
|
"message": "Update file",
|
|
},
|
|
expectError: false, // This returns a tool error, not a Go error
|
|
expectedErrMsg: "each file must have a path",
|
|
},
|
|
{
|
|
name: "fails when files contains object without content",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Get commit
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
// Missing content
|
|
},
|
|
},
|
|
"message": "Update file",
|
|
},
|
|
expectError: false, // This returns a tool error, not a Go error
|
|
expectedErrMsg: "each file must have content",
|
|
},
|
|
{
|
|
name: "fails to get branch reference",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
// Mock Repositories.Get to fail when trying to create branch from default
|
|
WithRequestMatchHandler(
|
|
GetReposByOwnerByRepo,
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "non-existent-branch",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
},
|
|
"message": "Update file",
|
|
},
|
|
expectError: false,
|
|
expectedErrMsg: "failed to create branch from default",
|
|
},
|
|
{
|
|
name: "fails to get base commit",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Fail to get commit
|
|
WithRequestMatchHandler(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockResponse(t, http.StatusNotFound, nil),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
},
|
|
"message": "Update file",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get base commit",
|
|
},
|
|
{
|
|
name: "fails to create tree",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Get commit
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
// Fail to create tree
|
|
WithRequestMatchHandler(
|
|
PostReposGitTreesByOwnerByRepo,
|
|
mockResponse(t, http.StatusInternalServerError, nil),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
},
|
|
"message": "Update file",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to create tree",
|
|
},
|
|
{
|
|
name: "successful push to empty repository",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference - first returns 409 for empty repo, second returns success after init
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
func() http.HandlerFunc {
|
|
callCount := 0
|
|
return func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
callCount++
|
|
if callCount == 1 {
|
|
// First call: empty repo
|
|
w.WriteHeader(http.StatusConflict)
|
|
response := map[string]any{
|
|
"message": "Git Repository is empty.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
// Second call: return the created reference
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(mockRef)
|
|
}
|
|
}
|
|
}(),
|
|
),
|
|
// Mock Repositories.Get to return default branch for initialization
|
|
WithRequestMatch(
|
|
GetReposByOwnerByRepo,
|
|
&github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
),
|
|
// Create initial file using Contents API
|
|
WithRequestMatchHandler(
|
|
PutReposContentsByOwnerByRepoByPath,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
err := json.NewDecoder(r.Body).Decode(&body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Initial commit", body["message"])
|
|
require.Equal(t, "main", body["branch"])
|
|
w.WriteHeader(http.StatusCreated)
|
|
response := &github.RepositoryContentResponse{
|
|
Commit: github.Commit{SHA: github.Ptr("abc123")},
|
|
}
|
|
b, _ := json.Marshal(response)
|
|
_, _ = w.Write(b)
|
|
}),
|
|
),
|
|
// Get the commit after initialization
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
// Create tree
|
|
WithRequestMatch(
|
|
PostReposGitTreesByOwnerByRepo,
|
|
mockTree,
|
|
),
|
|
// Create commit
|
|
WithRequestMatch(
|
|
PostReposGitCommitsByOwnerByRepo,
|
|
mockNewCommit,
|
|
),
|
|
// Update reference
|
|
WithRequestMatch(
|
|
PatchReposGitRefsByOwnerByRepoByRef,
|
|
mockUpdatedRef,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# Initial README\n\nFirst commit to empty repository.",
|
|
},
|
|
},
|
|
"message": "Initial commit",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockUpdatedRef,
|
|
},
|
|
{
|
|
name: "successful push multiple files to empty repository",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference - called twice: first for empty check, second after file creation
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
func() http.HandlerFunc {
|
|
callCount := 0
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
// First call: returns 409 Conflict for empty repo
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
response := map[string]any{
|
|
"message": "Git Repository is empty.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
// Second call: returns the updated reference after first file creation
|
|
w.WriteHeader(http.StatusOK)
|
|
b, _ := json.Marshal(&github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{SHA: github.Ptr("init456")},
|
|
})
|
|
_, _ = w.Write(b)
|
|
}
|
|
})
|
|
}(),
|
|
),
|
|
// Mock Repositories.Get to return default branch for initialization
|
|
WithRequestMatch(
|
|
GetReposByOwnerByRepo,
|
|
&github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
),
|
|
// Create initial empty README.md file using Contents API to initialize repo
|
|
WithRequestMatchHandler(
|
|
PutReposContentsByOwnerByRepoByPath,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
err := json.NewDecoder(r.Body).Decode(&body)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "Initial commit", body["message"])
|
|
require.Equal(t, "main", body["branch"])
|
|
// Verify it's an empty file
|
|
expectedContent := base64.StdEncoding.EncodeToString([]byte(""))
|
|
require.Equal(t, expectedContent, body["content"])
|
|
w.WriteHeader(http.StatusCreated)
|
|
response := &github.RepositoryContentResponse{
|
|
Content: &github.RepositoryContent{
|
|
SHA: github.Ptr("readme123"),
|
|
},
|
|
Commit: github.Commit{
|
|
SHA: github.Ptr("init456"),
|
|
Tree: &github.Tree{
|
|
SHA: github.Ptr("tree456"),
|
|
},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(response)
|
|
_, _ = w.Write(b)
|
|
}),
|
|
),
|
|
// Get the commit to retrieve parent SHA
|
|
WithRequestMatchHandler(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
response := &github.Commit{
|
|
SHA: github.Ptr("init456"),
|
|
Tree: &github.Tree{
|
|
SHA: github.Ptr("tree456"),
|
|
},
|
|
}
|
|
b, _ := json.Marshal(response)
|
|
_, _ = w.Write(b)
|
|
}),
|
|
),
|
|
// Create tree with all user files
|
|
WithRequestMatchHandler(
|
|
PostReposGitTreesByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"base_tree": "tree456",
|
|
"tree": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"content": "# Project\n\nProject README",
|
|
},
|
|
map[string]any{
|
|
"path": ".gitignore",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"content": "node_modules/\n*.log\n",
|
|
},
|
|
map[string]any{
|
|
"path": "src/main.js",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"content": "console.log('Hello World');\n",
|
|
},
|
|
},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockTree),
|
|
),
|
|
),
|
|
// Create commit with all user files
|
|
WithRequestMatchHandler(
|
|
PostReposGitCommitsByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"message": "Initial project setup",
|
|
"tree": "ghi789",
|
|
"parents": []any{"init456"},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockNewCommit),
|
|
),
|
|
),
|
|
// Update reference
|
|
WithRequestMatchHandler(
|
|
PatchReposGitRefsByOwnerByRepoByRef,
|
|
expectRequestBody(t, map[string]any{
|
|
"sha": "jkl012",
|
|
"force": false,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, mockUpdatedRef),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# Project\n\nProject README",
|
|
},
|
|
map[string]any{
|
|
"path": ".gitignore",
|
|
"content": "node_modules/\n*.log\n",
|
|
},
|
|
map[string]any{
|
|
"path": "src/main.js",
|
|
"content": "console.log('Hello World');\n",
|
|
},
|
|
},
|
|
"message": "Initial project setup",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockUpdatedRef,
|
|
},
|
|
{
|
|
name: "fails to create initial file in empty repository",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference returns 409 Conflict for empty repo
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
response := map[string]any{
|
|
"message": "Git Repository is empty.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}),
|
|
),
|
|
// Mock Repositories.Get to return default branch
|
|
WithRequestMatch(
|
|
GetReposByOwnerByRepo,
|
|
&github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
),
|
|
// Fail to create initial file using Contents API
|
|
WithRequestMatchHandler(
|
|
PutReposContentsByOwnerByRepoByPath,
|
|
mockResponse(t, http.StatusInternalServerError, nil),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
},
|
|
"message": "Initial commit",
|
|
},
|
|
expectError: false,
|
|
expectedErrMsg: "failed to initialize repository",
|
|
},
|
|
{
|
|
name: "fails to get reference after creating initial file in empty repository",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference - called twice
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
func() http.HandlerFunc {
|
|
callCount := 0
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
// First call: returns 409 Conflict for empty repo
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
response := map[string]any{
|
|
"message": "Git Repository is empty.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
} else {
|
|
// Second call: fails
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}
|
|
})
|
|
}(),
|
|
),
|
|
// Mock Repositories.Get to return default branch
|
|
WithRequestMatch(
|
|
GetReposByOwnerByRepo,
|
|
&github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
),
|
|
// Create initial file using Contents API
|
|
WithRequestMatch(
|
|
PutReposContentsByOwnerByRepoByPath,
|
|
&github.RepositoryContentResponse{
|
|
Content: &github.RepositoryContent{SHA: github.Ptr("readme123")},
|
|
Commit: github.Commit{SHA: github.Ptr("init456")},
|
|
},
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
},
|
|
"message": "Initial commit",
|
|
},
|
|
expectError: false,
|
|
expectedErrMsg: "failed to initialize repository",
|
|
},
|
|
{
|
|
name: "fails to get commit in empty repository with multiple files",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference returns 409 Conflict for empty repo
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
response := map[string]any{
|
|
"message": "Git Repository is empty.",
|
|
}
|
|
_ = json.NewEncoder(w).Encode(response)
|
|
}),
|
|
),
|
|
// Mock Repositories.Get to return default branch
|
|
WithRequestMatch(
|
|
GetReposByOwnerByRepo,
|
|
&github.Repository{
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
),
|
|
// Create initial file using Contents API
|
|
WithRequestMatch(
|
|
PutReposContentsByOwnerByRepoByPath,
|
|
&github.RepositoryContentResponse{
|
|
Content: &github.RepositoryContent{SHA: github.Ptr("readme123")},
|
|
Commit: github.Commit{SHA: github.Ptr("init456")},
|
|
},
|
|
),
|
|
// Fail to get commit
|
|
WithRequestMatchHandler(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockResponse(t, http.StatusInternalServerError, nil),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"branch": "main",
|
|
"files": []any{
|
|
map[string]any{
|
|
"path": "README.md",
|
|
"content": "# README",
|
|
},
|
|
map[string]any{
|
|
"path": "LICENSE",
|
|
"content": "MIT",
|
|
},
|
|
},
|
|
"message": "Initial commit",
|
|
},
|
|
expectError: false,
|
|
expectedErrMsg: "failed to initialize repository",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
if tc.expectedErrMsg != "" {
|
|
require.NotNil(t, result)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedRef github.Reference
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRef)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, *tc.expectedRef.Ref, *returnedRef.Ref)
|
|
assert.Equal(t, *tc.expectedRef.Object.SHA, *returnedRef.Object.SHA)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ListBranches(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ListBranches(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, "list_branches", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "page")
|
|
assert.Contains(t, schema.Properties, "perPage")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
// Setup mock branches for success case
|
|
mockBranches := []*github.Branch{
|
|
{
|
|
Name: github.Ptr("main"),
|
|
Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")},
|
|
},
|
|
{
|
|
Name: github.Ptr("develop"),
|
|
Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")},
|
|
},
|
|
}
|
|
|
|
// Test cases
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
mockResponses []MockBackendOption
|
|
wantErr bool
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "success",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"page": float64(2),
|
|
},
|
|
mockResponses: []MockBackendOption{
|
|
WithRequestMatch(
|
|
GetReposBranchesByOwnerByRepo,
|
|
mockBranches,
|
|
),
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing owner",
|
|
args: map[string]any{
|
|
"repo": "repo",
|
|
},
|
|
mockResponses: []MockBackendOption{},
|
|
wantErr: false,
|
|
errContains: "missing required parameter: owner",
|
|
},
|
|
{
|
|
name: "missing repo",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
},
|
|
mockResponses: []MockBackendOption{},
|
|
wantErr: false,
|
|
errContains: "missing required parameter: repo",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Create mock client
|
|
mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...))
|
|
deps := BaseDeps{
|
|
Client: mockClient,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create request
|
|
request := createMCPRequest(tt.args)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
if tt.wantErr {
|
|
require.NoError(t, err)
|
|
if tt.errContains != "" {
|
|
textContent := getErrorResult(t, result)
|
|
assert.Contains(t, textContent.Text, tt.errContains)
|
|
}
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
if tt.errContains != "" {
|
|
textContent := getTextResult(t, result)
|
|
assert.Contains(t, textContent.Text, tt.errContains)
|
|
return
|
|
}
|
|
|
|
textContent := getTextResult(t, result)
|
|
require.NotEmpty(t, textContent.Text)
|
|
|
|
// Verify response
|
|
var branches []*github.Branch
|
|
err = json.Unmarshal([]byte(textContent.Text), &branches)
|
|
require.NoError(t, err)
|
|
assert.Len(t, branches, 2)
|
|
assert.Equal(t, "main", *branches[0].Name)
|
|
assert.Equal(t, "develop", *branches[1].Name)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_DeleteFile(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := DeleteFile(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, "delete_file", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "path")
|
|
assert.Contains(t, schema.Properties, "message")
|
|
assert.Contains(t, schema.Properties, "branch")
|
|
// SHA is no longer required since we're using Git Data API
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path", "message", "branch"})
|
|
|
|
// Setup mock objects for Git Data API
|
|
mockRef := &github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("abc123"),
|
|
},
|
|
}
|
|
|
|
mockCommit := &github.Commit{
|
|
SHA: github.Ptr("abc123"),
|
|
Tree: &github.Tree{
|
|
SHA: github.Ptr("def456"),
|
|
},
|
|
}
|
|
|
|
mockTree := &github.Tree{
|
|
SHA: github.Ptr("ghi789"),
|
|
}
|
|
|
|
mockNewCommit := &github.Commit{
|
|
SHA: github.Ptr("jkl012"),
|
|
Message: github.Ptr("Delete example file"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedCommitSHA string
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful file deletion using Git Data API",
|
|
mockedClient: NewMockedHTTPClient(
|
|
// Get branch reference
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockRef,
|
|
),
|
|
// Get commit
|
|
WithRequestMatch(
|
|
GetReposGitCommitsByOwnerByRepoByCommitSHA,
|
|
mockCommit,
|
|
),
|
|
// Create tree
|
|
WithRequestMatchHandler(
|
|
PostReposGitTreesByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"base_tree": "def456",
|
|
"tree": []any{
|
|
map[string]any{
|
|
"path": "docs/example.md",
|
|
"mode": "100644",
|
|
"type": "blob",
|
|
"sha": nil,
|
|
},
|
|
},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockTree),
|
|
),
|
|
),
|
|
// Create commit
|
|
WithRequestMatchHandler(
|
|
PostReposGitCommitsByOwnerByRepo,
|
|
expectRequestBody(t, map[string]any{
|
|
"message": "Delete example file",
|
|
"tree": "ghi789",
|
|
"parents": []any{"abc123"},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockNewCommit),
|
|
),
|
|
),
|
|
// Update reference
|
|
WithRequestMatchHandler(
|
|
PatchReposGitRefsByOwnerByRepoByRef,
|
|
expectRequestBody(t, map[string]any{
|
|
"sha": "jkl012",
|
|
"force": false,
|
|
}).andThen(
|
|
mockResponse(t, http.StatusOK, &github.Reference{
|
|
Ref: github.Ptr("refs/heads/main"),
|
|
Object: &github.GitObject{
|
|
SHA: github.Ptr("jkl012"),
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/example.md",
|
|
"message": "Delete example file",
|
|
"branch": "main",
|
|
},
|
|
expectError: false,
|
|
expectedCommitSHA: "jkl012",
|
|
},
|
|
{
|
|
name: "file deletion fails - branch not found",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Reference not found"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"path": "docs/nonexistent.md",
|
|
"message": "Delete nonexistent file",
|
|
"branch": "nonexistent-branch",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get branch reference",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var response map[string]any
|
|
err = json.Unmarshal([]byte(textContent.Text), &response)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the response contains the expected commit
|
|
commit, ok := response["commit"].(map[string]any)
|
|
require.True(t, ok)
|
|
commitSHA, ok := commit["sha"].(string)
|
|
require.True(t, ok)
|
|
assert.Equal(t, tc.expectedCommitSHA, commitSHA)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ListTags(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ListTags(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, "list_tags", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
// Setup mock tags for success case
|
|
mockTags := []*github.RepositoryTag{
|
|
{
|
|
Name: github.Ptr("v1.0.0"),
|
|
Commit: &github.Commit{
|
|
SHA: github.Ptr("v1.0.0-tag-sha"),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"),
|
|
},
|
|
ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"),
|
|
TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"),
|
|
},
|
|
{
|
|
Name: github.Ptr("v0.9.0"),
|
|
Commit: &github.Commit{
|
|
SHA: github.Ptr("v0.9.0-tag-sha"),
|
|
URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"),
|
|
},
|
|
ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"),
|
|
TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedTags []*github.RepositoryTag
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful tags list",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposTagsByOwnerByRepo,
|
|
expectPath(
|
|
t,
|
|
"/repos/owner/repo/tags",
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockTags),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false,
|
|
expectedTags: mockTags,
|
|
},
|
|
{
|
|
name: "list tags fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposTagsByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to list tags",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Parse and verify the result
|
|
var returnedTags []MinimalTag
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedTags)
|
|
require.NoError(t, err)
|
|
|
|
// Verify each tag
|
|
require.Equal(t, len(tc.expectedTags), len(returnedTags))
|
|
for i, expectedTag := range tc.expectedTags {
|
|
assert.Equal(t, *expectedTag.Name, returnedTags[i].Name)
|
|
assert.Equal(t, *expectedTag.Commit.SHA, returnedTags[i].SHA)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetTag(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := GetTag(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, "get_tag", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "tag")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"})
|
|
|
|
mockAnnotatedTagRef := &github.Reference{
|
|
Ref: github.Ptr("refs/tags/v1.0.0"),
|
|
Object: &github.GitObject{
|
|
Type: github.Ptr("tag"),
|
|
SHA: github.Ptr("v1.0.0-tag-sha"),
|
|
},
|
|
}
|
|
|
|
mockLightweightTagRef := &github.Reference{
|
|
Ref: github.Ptr("refs/tags/v1.0.1"),
|
|
Object: &github.GitObject{
|
|
Type: github.Ptr("commit"),
|
|
SHA: github.Ptr("abc123"),
|
|
},
|
|
}
|
|
|
|
mockTagObj := &github.Tag{
|
|
SHA: github.Ptr("v1.0.0-tag-sha"),
|
|
Tag: github.Ptr("v1.0.0"),
|
|
Message: github.Ptr("Release v1.0.0"),
|
|
Object: &github.GitObject{
|
|
Type: github.Ptr("commit"),
|
|
SHA: github.Ptr("abc123"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedTag *github.Tag
|
|
expectedRef *github.Reference
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful tag retrieval",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
expectPath(
|
|
t,
|
|
"/repos/owner/repo/git/ref/tags/v1.0.0",
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockAnnotatedTagRef),
|
|
),
|
|
),
|
|
WithRequestMatchHandler(
|
|
GetReposGitTagsByOwnerByRepoByTagSHA,
|
|
expectPath(
|
|
t,
|
|
"/repos/owner/repo/git/tags/v1.0.0-tag-sha",
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockTagObj),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: false,
|
|
expectedTag: mockTagObj,
|
|
},
|
|
{
|
|
name: "tag reference not found",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Reference does not exist"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get tag reference",
|
|
},
|
|
{
|
|
name: "tag object not found",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatch(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
mockAnnotatedTagRef,
|
|
),
|
|
WithRequestMatchHandler(
|
|
GetReposGitTagsByOwnerByRepoByTagSHA,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get tag object",
|
|
},
|
|
{
|
|
name: "successful lightweight tag retrieval",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
expectPath(
|
|
t,
|
|
"/repos/owner/repo/git/ref/tags/v1.0.1",
|
|
).andThen(
|
|
mockResponse(t, http.StatusOK, mockLightweightTagRef),
|
|
),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.1",
|
|
},
|
|
expectError: false,
|
|
expectedRef: mockLightweightTagRef,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
// Parse the result and get the text content if no error
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Parse and verify the result - annotated tag (full tag object)
|
|
if tc.expectedTag != nil {
|
|
var returnedTag github.Tag
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedTag)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, tc.expectedTag.GetSHA(), returnedTag.GetSHA())
|
|
assert.Equal(t, tc.expectedTag.GetTag(), returnedTag.GetTag())
|
|
assert.Equal(t, tc.expectedTag.GetMessage(), returnedTag.GetMessage())
|
|
assert.Equal(t, tc.expectedTag.Object.GetType(), returnedTag.Object.GetType())
|
|
assert.Equal(t, tc.expectedTag.Object.GetSHA(), returnedTag.Object.GetSHA())
|
|
}
|
|
|
|
// Parse and verify the result - lightweight tag (reference only)
|
|
if tc.expectedRef != nil {
|
|
var returnedRef github.Reference
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRef)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, tc.expectedRef.GetRef(), returnedRef.GetRef())
|
|
assert.Equal(t, tc.expectedRef.Object.GetType(), returnedRef.Object.GetType())
|
|
assert.Equal(t, tc.expectedRef.Object.GetSHA(), returnedRef.Object.GetSHA())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ListReleases(t *testing.T) {
|
|
serverTool := ListReleases(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
// ListReleases is the FeatureFlagFieldsParam-enabled variant; it owns the
|
|
// _ff_<flag> snapshot. The canonical list_releases.snap is owned by
|
|
// LegacyListReleases (see Test_LegacyListReleases_Definition).
|
|
require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool))
|
|
require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable)
|
|
|
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
|
|
|
|
assert.Equal(t, "list_releases", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "fields")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
mockReleases := []*github.RepositoryRelease{
|
|
{
|
|
ID: 1,
|
|
TagName: "v1.0.0",
|
|
Name: github.Ptr("First Release"),
|
|
},
|
|
{
|
|
ID: 2,
|
|
TagName: "v0.9.0",
|
|
Name: github.Ptr("Beta Release"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedResult []*github.RepositoryRelease
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful releases list",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatch(
|
|
GetReposReleasesByOwnerByRepo,
|
|
mockReleases,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockReleases,
|
|
},
|
|
{
|
|
name: "releases list fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposReleasesByOwnerByRepo,
|
|
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",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to list releases",
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
textContent := getTextResult(t, result)
|
|
var returnedReleases []MinimalRelease
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedReleases)
|
|
require.NoError(t, err)
|
|
assert.Len(t, returnedReleases, len(tc.expectedResult))
|
|
for i := range returnedReleases {
|
|
assert.Equal(t, tc.expectedResult[i].TagName, returnedReleases[i].TagName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetLatestRelease(t *testing.T) {
|
|
serverTool := GetLatestRelease(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, "get_latest_release", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
mockRelease := &github.RepositoryRelease{
|
|
ID: 1,
|
|
TagName: "v1.0.0",
|
|
Name: github.Ptr("First Release"),
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedResult *github.RepositoryRelease
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful latest release fetch",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatch(
|
|
GetReposReleasesLatestByOwnerByRepo,
|
|
mockRelease,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockRelease,
|
|
},
|
|
{
|
|
name: "latest release fetch fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposReleasesLatestByOwnerByRepo,
|
|
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",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to get latest release",
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
textContent := getTextResult(t, result)
|
|
var returnedRelease github.RepositoryRelease
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_GetReleaseByTag(t *testing.T) {
|
|
serverTool := GetReleaseByTag(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, "get_release_by_tag", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "tag")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"})
|
|
|
|
mockRelease := &github.RepositoryRelease{
|
|
ID: 1,
|
|
TagName: "v1.0.0",
|
|
Name: github.Ptr("Release v1.0.0"),
|
|
Body: github.Ptr("This is the first stable release."),
|
|
Assets: []*github.ReleaseAsset{
|
|
{
|
|
ID: github.Ptr(int64(1)),
|
|
Name: github.Ptr("release-v1.0.0.tar.gz"),
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedResult *github.RepositoryRelease
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful release by tag fetch",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatch(
|
|
GetReposReleasesTagsByOwnerByRepoByTag,
|
|
mockRelease,
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: false,
|
|
expectedResult: mockRelease,
|
|
},
|
|
{
|
|
name: "missing owner parameter",
|
|
mockedClient: NewMockedHTTPClient(),
|
|
requestArgs: map[string]any{
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: false, // Returns tool error, not Go error
|
|
expectedErrMsg: "missing required parameter: owner",
|
|
},
|
|
{
|
|
name: "missing repo parameter",
|
|
mockedClient: NewMockedHTTPClient(),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: false, // Returns tool error, not Go error
|
|
expectedErrMsg: "missing required parameter: repo",
|
|
},
|
|
{
|
|
name: "missing tag parameter",
|
|
mockedClient: NewMockedHTTPClient(),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
expectError: false, // Returns tool error, not Go error
|
|
expectedErrMsg: "missing required parameter: tag",
|
|
},
|
|
{
|
|
name: "release by tag not found",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposReleasesTagsByOwnerByRepoByTag,
|
|
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",
|
|
"tag": "v999.0.0",
|
|
},
|
|
expectError: false, // API errors return tool errors, not Go errors
|
|
expectedErrMsg: "failed to get release by tag: v999.0.0",
|
|
},
|
|
{
|
|
name: "server error",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposReleasesTagsByOwnerByRepoByTag,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"tag": "v1.0.0",
|
|
},
|
|
expectError: false, // API errors return tool errors, not Go errors
|
|
expectedErrMsg: "failed to get release by tag: v1.0.0",
|
|
},
|
|
}
|
|
|
|
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)
|
|
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
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 returnedRelease github.RepositoryRelease
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, tc.expectedResult.ID, returnedRelease.ID)
|
|
assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName)
|
|
assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name)
|
|
if tc.expectedResult.Body != nil {
|
|
assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body)
|
|
}
|
|
if len(tc.expectedResult.Assets) > 0 {
|
|
require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets))
|
|
assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test_GetReleaseByTag_IFC_FeatureFlag verifies the IFC label on
|
|
// get_release_by_tag. The label is only present when the ifc_labels flag is
|
|
// enabled, and confidentiality is public only for a non-draft release on a
|
|
// public repo. A draft release is visible only to push-access users, so even
|
|
// on a public repo it must be labeled private. Guards against the same
|
|
// under-classification fixed for repository security advisories.
|
|
func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := GetReleaseByTag(translations.NullTranslationHelper)
|
|
|
|
makeRelease := func(draft bool) *github.RepositoryRelease {
|
|
return &github.RepositoryRelease{
|
|
ID: 1,
|
|
TagName: "v1.0.0",
|
|
Name: github.Ptr("v1.0.0"),
|
|
Draft: draft,
|
|
}
|
|
}
|
|
|
|
makeMockClient := func(isPrivate bool, release *github.RepositoryRelease) *http.Client {
|
|
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
GetReposReleasesTagsByOwnerByRepoByTag: mockResponse(t, http.StatusOK, release),
|
|
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
|
|
"name": "repo",
|
|
"private": isPrivate,
|
|
}),
|
|
})
|
|
}
|
|
|
|
reqParams := map[string]any{"owner": "owner", "repo": "repo", "tag": "v1.0.0"}
|
|
|
|
readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) {
|
|
t.Helper()
|
|
if result.Meta == nil {
|
|
return nil, false
|
|
}
|
|
label, ok := result.Meta["ifc"]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
labelJSON, err := json.Marshal(label)
|
|
require.NoError(t, err)
|
|
var labelMap map[string]any
|
|
require.NoError(t, json.Unmarshal(labelJSON, &labelMap))
|
|
return labelMap, true
|
|
}
|
|
|
|
t.Run("feature flag disabled omits ifc label", func(t *testing.T) {
|
|
t.Parallel()
|
|
deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false)))}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
assert.Nil(t, result.Meta)
|
|
})
|
|
|
|
t.Run("public repo with published release is public", func(t *testing.T) {
|
|
t.Parallel()
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false))),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
label, ok := readIFC(t, result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "trusted", label["integrity"])
|
|
assert.Equal(t, "public", label["confidentiality"])
|
|
})
|
|
|
|
t.Run("public repo with draft release is private", func(t *testing.T) {
|
|
t.Parallel()
|
|
// Reviewer-class scenario: a draft release on a public repo is not
|
|
// world-readable, so the label must not be public.
|
|
deps := BaseDeps{
|
|
Client: mustNewGHClient(t, makeMockClient(false, makeRelease(true))),
|
|
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(reqParams)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
label, ok := readIFC(t, result)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "trusted", label["integrity"])
|
|
assert.Equal(t, "private", label["confidentiality"], "draft release on public repo must be private")
|
|
})
|
|
}
|
|
|
|
func Test_looksLikeSHA(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "full 40-character SHA",
|
|
input: "abc123def456abc123def456abc123def456abc1",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "too short",
|
|
input: "abc123def456abc123def45",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "too long - 41 characters",
|
|
input: "abc123def456abc123def456abc123def456abc12",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "contains invalid character - space",
|
|
input: "abc123def456abc123def456 bc123def456abc1",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "contains invalid character - dash",
|
|
input: "abc123def456abc123d-f456abc123def456abc1",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "contains invalid character - g",
|
|
input: "abc123def456gbc123def456abc123def456abc1",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "branch name with slash",
|
|
input: "feature/branch",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "empty string",
|
|
input: "",
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "all zeros SHA",
|
|
input: "0000000000000000000000000000000000000000",
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "all f's SHA",
|
|
input: "ffffffffffffffffffffffffffffffffffffffff",
|
|
expected: true,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := looksLikeSHA(tc.input)
|
|
assert.Equal(t, tc.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_filterPaths(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
tree []*github.TreeEntry
|
|
path string
|
|
maxResults int
|
|
expected []string
|
|
}{
|
|
{
|
|
name: "file name",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")},
|
|
{Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")},
|
|
{Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")},
|
|
{Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")},
|
|
},
|
|
path: "foo.txt",
|
|
maxResults: -1,
|
|
expected: []string{"folder/foo.txt", "nested/folder/foo.txt"},
|
|
},
|
|
{
|
|
name: "dir name",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")},
|
|
{Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")},
|
|
},
|
|
path: "folder/",
|
|
maxResults: -1,
|
|
expected: []string{"folder/", "nested/folder/"},
|
|
},
|
|
{
|
|
name: "dir and file match",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("name"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("name"), Type: github.Ptr("blob")},
|
|
},
|
|
path: "name", // No trailing slash can match both files and directories
|
|
maxResults: -1,
|
|
expected: []string{"name/", "name"},
|
|
},
|
|
{
|
|
name: "dir only match",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("name"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("name"), Type: github.Ptr("blob")},
|
|
},
|
|
path: "name/", // Trialing slash ensures only directories are matched
|
|
maxResults: -1,
|
|
expected: []string{"name/"},
|
|
},
|
|
{
|
|
name: "max results limit 2",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")},
|
|
},
|
|
path: "folder/",
|
|
maxResults: 2,
|
|
expected: []string{"folder/", "nested/folder/"},
|
|
},
|
|
{
|
|
name: "max results limit 1",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")},
|
|
},
|
|
path: "folder/",
|
|
maxResults: 1,
|
|
expected: []string{"folder/"},
|
|
},
|
|
{
|
|
name: "max results limit 0",
|
|
tree: []*github.TreeEntry{
|
|
{Path: github.Ptr("folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")},
|
|
{Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")},
|
|
},
|
|
path: "folder/",
|
|
maxResults: 0,
|
|
expected: []string{},
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := filterPaths(tc.tree, tc.path, tc.maxResults)
|
|
assert.Equal(t, tc.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_resolveGitReference(t *testing.T) {
|
|
ctx := context.Background()
|
|
owner := "owner"
|
|
repo := "repo"
|
|
|
|
tests := []struct {
|
|
name string
|
|
ref string
|
|
sha string
|
|
mockSetup func() *http.Client
|
|
expectedOutput *raw.ContentOpts
|
|
expectError bool
|
|
errorContains string
|
|
}{
|
|
{
|
|
name: "sha takes precedence over ref",
|
|
ref: "refs/heads/main",
|
|
sha: "123sha456",
|
|
mockSetup: func() *http.Client {
|
|
// No API calls should be made when SHA is provided
|
|
return NewMockedHTTPClient()
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
SHA: "123sha456",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "use default branch if ref and sha both empty",
|
|
ref: "",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`))
|
|
}),
|
|
),
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Contains(t, r.URL.Path, "/git/ref/heads/main")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/heads/main",
|
|
SHA: "main-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "fully qualified ref passed through unchanged",
|
|
ref: "refs/heads/feature-branch",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/heads/feature-branch",
|
|
SHA: "feature-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "short branch name resolves to refs/heads/",
|
|
ref: "main",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if strings.Contains(r.URL.Path, "/git/ref/heads/main") {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": "main-sha"}}`))
|
|
} else {
|
|
t.Errorf("Unexpected path: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/heads/main",
|
|
SHA: "main-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "short tag name falls back to refs/tags/ when branch not found",
|
|
ref: "v1.0.0",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"):
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
case strings.Contains(r.URL.Path, "/git/ref/tags/v1.0.0"):
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`))
|
|
default:
|
|
t.Errorf("Unexpected path: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/tags/v1.0.0",
|
|
SHA: "tag-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "heads/ prefix gets refs/ prepended",
|
|
ref: "heads/feature-branch",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/heads/feature-branch", "object": {"sha": "feature-sha"}}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/heads/feature-branch",
|
|
SHA: "feature-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "tags/ prefix gets refs/ prepended",
|
|
ref: "tags/v1.0.0",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/tags/v1.0.0", "object": {"sha": "tag-sha"}}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/tags/v1.0.0",
|
|
SHA: "tag-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "invalid short name that doesn't exist as branch or tag",
|
|
ref: "nonexistent",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
// Both branch and tag attempts should return 404
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectError: true,
|
|
errorContains: "could not resolve ref \"nonexistent\" as a branch or a tag",
|
|
},
|
|
{
|
|
name: "fully qualified pull request ref",
|
|
ref: "refs/pull/123/head",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
return NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetReposGitRefByOwnerByRepoByRef,
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(`{"ref": "refs/pull/123/head", "object": {"sha": "pr-sha"}}`))
|
|
}),
|
|
),
|
|
)
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
Ref: "refs/pull/123/head",
|
|
SHA: "pr-sha",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "ref looks like full SHA with empty sha parameter",
|
|
ref: "abc123def456abc123def456abc123def456abc1",
|
|
sha: "",
|
|
mockSetup: func() *http.Client {
|
|
// No API calls should be made when ref looks like SHA
|
|
return NewMockedHTTPClient()
|
|
},
|
|
expectedOutput: &raw.ContentOpts{
|
|
SHA: "abc123def456abc123def456abc123def456abc1",
|
|
},
|
|
expectError: false,
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockSetup())
|
|
opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha)
|
|
|
|
if tc.expectError {
|
|
require.Error(t, err)
|
|
if tc.errorContains != "" {
|
|
assert.Contains(t, err.Error(), tc.errorContains)
|
|
}
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, opts)
|
|
|
|
if tc.expectedOutput.SHA != "" {
|
|
assert.Equal(t, tc.expectedOutput.SHA, opts.SHA)
|
|
}
|
|
if tc.expectedOutput.Ref != "" {
|
|
assert.Equal(t, tc.expectedOutput.Ref, opts.Ref)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_ListStarredRepositories(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ListStarredRepositories(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, "list_starred_repositories", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "username")
|
|
assert.Contains(t, schema.Properties, "sort")
|
|
assert.Contains(t, schema.Properties, "direction")
|
|
assert.Contains(t, schema.Properties, "page")
|
|
assert.Contains(t, schema.Properties, "perPage")
|
|
assert.Empty(t, schema.Required) // All parameters are optional
|
|
|
|
// Setup mock starred repositories
|
|
starredAt := time.Now().Add(-24 * time.Hour)
|
|
updatedAt := time.Now().Add(-2 * time.Hour)
|
|
mockStarredRepos := []*github.StarredRepository{
|
|
{
|
|
StarredAt: &github.Timestamp{Time: starredAt},
|
|
Repository: &github.Repository{
|
|
ID: github.Ptr(int64(12345)),
|
|
Name: github.Ptr("awesome-repo"),
|
|
FullName: github.Ptr("owner/awesome-repo"),
|
|
Description: github.Ptr("An awesome repository"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"),
|
|
Language: github.Ptr("Go"),
|
|
StargazersCount: github.Ptr(100),
|
|
ForksCount: github.Ptr(25),
|
|
OpenIssuesCount: github.Ptr(5),
|
|
UpdatedAt: &github.Timestamp{Time: updatedAt},
|
|
Private: github.Ptr(false),
|
|
Fork: github.Ptr(false),
|
|
Archived: github.Ptr(false),
|
|
DefaultBranch: github.Ptr("main"),
|
|
},
|
|
},
|
|
{
|
|
StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)},
|
|
Repository: &github.Repository{
|
|
ID: github.Ptr(int64(67890)),
|
|
Name: github.Ptr("cool-project"),
|
|
FullName: github.Ptr("user/cool-project"),
|
|
Description: github.Ptr("A very cool project"),
|
|
HTMLURL: github.Ptr("https://github.com/user/cool-project"),
|
|
Language: github.Ptr("Python"),
|
|
StargazersCount: github.Ptr(500),
|
|
ForksCount: github.Ptr(75),
|
|
OpenIssuesCount: github.Ptr(10),
|
|
UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)},
|
|
Private: github.Ptr(false),
|
|
Fork: github.Ptr(true),
|
|
Archived: github.Ptr(false),
|
|
DefaultBranch: github.Ptr("master"),
|
|
},
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedErrMsg string
|
|
expectedCount int
|
|
}{
|
|
{
|
|
name: "successful list for authenticated user",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetUserStarred,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(MustMarshal(mockStarredRepos))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{},
|
|
expectError: false,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "successful list for specific user",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetUsersStarredByUsername,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(MustMarshal(mockStarredRepos))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"username": "testuser",
|
|
},
|
|
expectError: false,
|
|
expectedCount: 2,
|
|
},
|
|
{
|
|
name: "list fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
GetUserStarred,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to list starred repositories",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NotNil(t, result)
|
|
textResult, ok := result.Content[0].(*mcp.TextContent)
|
|
require.True(t, ok, "Expected text content")
|
|
assert.Contains(t, textResult.Text, tc.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// Parse the result and get the text content
|
|
textContent := getTextResult(t, result)
|
|
|
|
// Unmarshal and verify the result
|
|
var returnedRepos []MinimalRepository
|
|
err = json.Unmarshal([]byte(textContent.Text), &returnedRepos)
|
|
require.NoError(t, err)
|
|
|
|
assert.Len(t, returnedRepos, tc.expectedCount)
|
|
if tc.expectedCount > 0 {
|
|
assert.Equal(t, "awesome-repo", returnedRepos[0].Name)
|
|
assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_StarRepository(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := StarRepository(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, "star_repository", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful star",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
PutUserStarredByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "star fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
PutUserStarredByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "nonexistent",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to star repository",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NotNil(t, result)
|
|
textResult, ok := result.Content[0].(*mcp.TextContent)
|
|
require.True(t, ok, "Expected text content")
|
|
assert.Contains(t, textResult.Text, tc.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// Parse the result and get the text content
|
|
textContent := getTextResult(t, result)
|
|
assert.Contains(t, textContent.Text, "Successfully starred repository")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_UnstarRepository(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := UnstarRepository(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, "unstar_repository", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful unstar",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
DeleteUserStarredByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "unstar fails",
|
|
mockedClient: NewMockedHTTPClient(
|
|
WithRequestMatchHandler(
|
|
DeleteUserStarredByOwnerByRepo,
|
|
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "nonexistent",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to unstar repository",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
// Setup client with mock
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Call handler
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
// Verify results
|
|
if tc.expectError {
|
|
require.NotNil(t, result)
|
|
textResult, ok := result.Content[0].(*mcp.TextContent)
|
|
require.True(t, ok, "Expected text content")
|
|
assert.Contains(t, textResult.Text, tc.expectedErrMsg)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
// Parse the result and get the text content
|
|
textContent := getTextResult(t, result)
|
|
assert.Contains(t, textContent.Text, "Successfully unstarred repository")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
func Test_GetFileBlame(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := GetFileBlame(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
// get_file_blame is gated so it is not advertised unless the feature flag
|
|
// (or insiders mode) opts it in.
|
|
assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag")
|
|
|
|
schema, ok := tool.InputSchema.(*jsonschema.Schema)
|
|
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
|
|
assert.Equal(t, "get_file_blame", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "perPage", "after"} {
|
|
assert.Contains(t, schema.Properties, key, "schema missing property %q", key)
|
|
}
|
|
assert.NotContains(t, schema.Properties, "page")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"})
|
|
require.NotNil(t, tool.Annotations)
|
|
assert.True(t, tool.Annotations.ReadOnlyHint, "blame is read-only")
|
|
|
|
// blameQueryShape is the GraphQL query shape used by all
|
|
// network-touching subtests below. Defined once so changes to the wire
|
|
// schema are made in a single place.
|
|
type blameQueryShape = struct {
|
|
Repository struct {
|
|
DefaultBranchRef struct {
|
|
Name githubv4.String
|
|
}
|
|
Object struct {
|
|
Typename githubv4.String `graphql:"__typename"`
|
|
Commit blameCommitFragment `graphql:"... on Commit"`
|
|
Tag struct {
|
|
Target struct {
|
|
Typename githubv4.String `graphql:"__typename"`
|
|
Commit blameCommitFragment `graphql:"... on Commit"`
|
|
}
|
|
} `graphql:"... on Tag"`
|
|
} `graphql:"object(expression: $ref)"`
|
|
} `graphql:"repository(owner: $owner, name: $repo)"`
|
|
}
|
|
|
|
makeBlameVars := func(owner, repo, ref, path string) map[string]any {
|
|
return map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"repo": githubv4.String(repo),
|
|
"ref": githubv4.String(ref),
|
|
"path": githubv4.String(path),
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedErrMsg string
|
|
validateResponse func(t *testing.T, result string)
|
|
}{
|
|
{
|
|
name: "successful blame using default branch (HEAD)",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "HEAD", "README.md"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{
|
|
"ranges": []map[string]any{
|
|
{
|
|
"startingLine": 1, "endingLine": 5, "age": 2,
|
|
"commit": map[string]any{
|
|
"oid": "abc123def456",
|
|
"message": "Initial commit\n\nLong body that should not appear in the response.",
|
|
"committedDate": "2024-01-01T12:00:00Z",
|
|
"author": map[string]any{
|
|
"name": "John Doe", "email": "john@example.com",
|
|
"user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
// Same commit as the first range -> must be deduplicated.
|
|
"startingLine": 6, "endingLine": 7, "age": 2,
|
|
"commit": map[string]any{
|
|
"oid": "abc123def456",
|
|
"message": "Initial commit\n\nLong body that should not appear in the response.",
|
|
"committedDate": "2024-01-01T12:00:00Z",
|
|
"author": map[string]any{
|
|
"name": "John Doe", "email": "john@example.com",
|
|
"user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"startingLine": 8, "endingLine": 10, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "def456ghi789",
|
|
"message": "Update README",
|
|
"committedDate": "2024-01-02T15:30:00Z",
|
|
"author": map[string]any{
|
|
"name": "Jane Smith", "email": "jane@example.com",
|
|
"user": map[string]any{"login": "janesmith", "url": "https://github.com/janesmith"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "README.md",
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
assert.Equal(t, "testowner/testrepo", br.Repository)
|
|
assert.Equal(t, "README.md", br.Path)
|
|
assert.Equal(t, "main", br.Ref, "ref should resolve to default branch name")
|
|
assert.False(t, br.Truncated)
|
|
assert.Equal(t, 3, br.TotalRanges)
|
|
assert.False(t, br.PageInfo.HasNextPage)
|
|
assert.False(t, br.PageInfo.HasPreviousPage)
|
|
assert.NotEmpty(t, br.PageInfo.StartCursor)
|
|
assert.NotEmpty(t, br.PageInfo.EndCursor)
|
|
require.Len(t, br.Ranges, 3)
|
|
// Commits map is deduplicated.
|
|
require.Len(t, br.Commits, 2)
|
|
require.Contains(t, br.Commits, "abc123def456")
|
|
require.Contains(t, br.Commits, "def456ghi789")
|
|
// Multi-line message must be reduced to its headline.
|
|
assert.Equal(t, "Initial commit", br.Commits["abc123def456"].MessageHeadline)
|
|
assert.NotContains(t, result, "Long body that should not appear")
|
|
// Login/URL pointers populated.
|
|
require.NotNil(t, br.Commits["abc123def456"].Author.Login)
|
|
assert.Equal(t, "johndoe", *br.Commits["abc123def456"].Author.Login)
|
|
},
|
|
},
|
|
{
|
|
name: "successful blame with explicit ref",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "feature-branch", "src/main.go"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{
|
|
"ranges": []map[string]any{
|
|
{
|
|
"startingLine": 1, "endingLine": 3, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "xyz789abc123",
|
|
"message": "Add main function",
|
|
"committedDate": "2024-01-03T10:00:00Z",
|
|
"author": map[string]any{
|
|
"name": "Bob Developer", "email": "bob@example.com",
|
|
"user": nil,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "src/main.go",
|
|
"ref": "feature-branch",
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
assert.Equal(t, "feature-branch", br.Ref, "explicit ref echoed back")
|
|
require.Len(t, br.Ranges, 1)
|
|
require.Contains(t, br.Commits, "xyz789abc123")
|
|
assert.Nil(t, br.Commits["xyz789abc123"].Author.Login, "anonymous author has no login")
|
|
},
|
|
},
|
|
{
|
|
name: "successful blame with annotated tag ref",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "v1.0.0", "src/tagged.go"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Tag",
|
|
"target": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{
|
|
"ranges": []map[string]any{
|
|
{
|
|
"startingLine": 1, "endingLine": 2, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "taggedcommit123",
|
|
"message": "Tagged release commit",
|
|
"committedDate": "2024-01-04T10:00:00Z",
|
|
"author": map[string]any{"name": "Tag Author", "email": "tag@example.com", "user": nil},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "src/tagged.go",
|
|
"ref": "v1.0.0",
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
assert.Equal(t, "v1.0.0", br.Ref, "explicit annotated tag ref echoed back")
|
|
require.Len(t, br.Ranges, 1)
|
|
assert.Equal(t, "taggedcommit123", br.Ranges[0].CommitSHA)
|
|
require.Contains(t, br.Commits, "taggedcommit123")
|
|
assert.Equal(t, "Tagged release commit", br.Commits["taggedcommit123"].MessageHeadline,
|
|
"commit metadata threads through the Tag.Target.Commit path")
|
|
assert.Equal(t, "Tag Author", br.Commits["taggedcommit123"].Author.Name)
|
|
},
|
|
},
|
|
{
|
|
name: "empty blame ranges",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "HEAD", "EMPTY.md"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{"ranges": []map[string]any{}},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "EMPTY.md",
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
assert.Equal(t, 0, br.TotalRanges)
|
|
assert.Empty(t, br.Ranges)
|
|
assert.Empty(t, br.Commits)
|
|
assert.False(t, br.PageInfo.HasNextPage)
|
|
assert.False(t, br.PageInfo.HasPreviousPage)
|
|
assert.False(t, br.Truncated)
|
|
// Ranges should marshal as an empty array, not null.
|
|
assert.Contains(t, result, `"ranges":[]`)
|
|
},
|
|
},
|
|
{
|
|
name: "ref resolves to non-commit object",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "main", "docs"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Tree",
|
|
"blame": map[string]any{"ranges": []map[string]any{}},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "docs",
|
|
"ref": "main",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "did not resolve to a commit",
|
|
},
|
|
{
|
|
name: "ref not found",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "no-such-ref", "README.md"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": nil,
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "README.md",
|
|
"ref": "no-such-ref",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "was not found",
|
|
},
|
|
{
|
|
name: "annotated tag target is not commit",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "tree-tag", "README.md"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Tag",
|
|
"target": map[string]any{"__typename": "Tree"},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "README.md",
|
|
"ref": "tree-tag",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "tag target did not resolve to a commit",
|
|
},
|
|
{
|
|
name: "line-range filter clamps and drops ranges",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "HEAD", "src/big.go"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{
|
|
"ranges": []map[string]any{
|
|
{
|
|
"startingLine": 1, "endingLine": 5, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "a", "email": "a@x", "user": nil},
|
|
},
|
|
},
|
|
{
|
|
"startingLine": 6, "endingLine": 12, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "b", "email": "b@x", "user": nil},
|
|
},
|
|
},
|
|
{
|
|
"startingLine": 13, "endingLine": 20, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "c", "email": "c@x", "user": nil},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "src/big.go",
|
|
"start_line": float64(8),
|
|
"end_line": float64(15),
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
// First range (1-5) is dropped; middle clamped to 8-12;
|
|
// last clamped to 13-15.
|
|
require.Len(t, br.Ranges, 2)
|
|
assert.Equal(t, 8, br.Ranges[0].StartingLine)
|
|
assert.Equal(t, 12, br.Ranges[0].EndingLine)
|
|
assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA)
|
|
assert.Equal(t, 13, br.Ranges[1].StartingLine)
|
|
assert.Equal(t, 15, br.Ranges[1].EndingLine)
|
|
assert.Equal(t, "sha-C", br.Ranges[1].CommitSHA)
|
|
assert.NotContains(t, br.Commits, "sha-A", "filtered-out commit must not appear")
|
|
},
|
|
},
|
|
{
|
|
name: "cursor pagination returns requested page",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "HEAD", "src/paged.go"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{
|
|
"ranges": []map[string]any{
|
|
{
|
|
"startingLine": 1, "endingLine": 1, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "a", "email": "a@x", "user": nil},
|
|
},
|
|
},
|
|
{
|
|
"startingLine": 2, "endingLine": 2, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "b", "email": "b@x", "user": nil},
|
|
},
|
|
},
|
|
{
|
|
"startingLine": 3, "endingLine": 3, "age": 1,
|
|
"commit": map[string]any{
|
|
"oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "c", "email": "c@x", "user": nil},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "src/paged.go",
|
|
"perPage": float64(1),
|
|
"after": encodeBlameCursor(1),
|
|
},
|
|
validateResponse: func(t *testing.T, result string) {
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(result), &br))
|
|
assert.Equal(t, 3, br.TotalRanges)
|
|
require.Len(t, br.Ranges, 1)
|
|
assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA)
|
|
require.Len(t, br.Commits, 1)
|
|
require.Contains(t, br.Commits, "sha-B")
|
|
assert.True(t, br.PageInfo.HasNextPage)
|
|
assert.True(t, br.PageInfo.HasPreviousPage)
|
|
assert.Equal(t, encodeBlameCursor(1), br.PageInfo.StartCursor)
|
|
assert.Equal(t, encodeBlameCursor(2), br.PageInfo.EndCursor)
|
|
},
|
|
},
|
|
{
|
|
name: "GraphQL error is surfaced",
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("testowner", "testrepo", "main", "nonexistent.txt"),
|
|
githubv4mock.ErrorResponse("file not found"),
|
|
),
|
|
),
|
|
requestArgs: map[string]any{
|
|
"owner": "testowner",
|
|
"repo": "testrepo",
|
|
"path": "nonexistent.txt",
|
|
"ref": "main",
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "file not found",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
client := githubv4.NewClient(tc.mockedClient)
|
|
deps := BaseDeps{GQLClient: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
textContent := getTextResult(t, result)
|
|
if tc.validateResponse != nil {
|
|
tc.validateResponse(t, textContent.Text)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Path validation must short-circuit before any network call. We supply
|
|
// a client with no matchers so any HTTP attempt would fail loudly.
|
|
t.Run("path validation rejects bad inputs", func(t *testing.T) {
|
|
client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
|
|
deps := BaseDeps{GQLClient: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
cases := []struct {
|
|
name string
|
|
path string
|
|
want string
|
|
}{
|
|
{"empty", " ", "must not be empty"},
|
|
{"absolute", "/etc/passwd", "must be relative"},
|
|
{"traversal", "src/../../../etc/passwd", "must not contain '..'"},
|
|
{"control char", "src/\x00bad.go", "control characters"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
req := createMCPRequest(map[string]any{
|
|
"owner": "o", "repo": "r", "path": c.path,
|
|
})
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &req)
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError, "expected validation error for %q", c.path)
|
|
assert.Contains(t, getErrorResult(t, result).Text, c.want)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Line-window and cursor pagination validation also short-circuits.
|
|
t.Run("line-range argument validation", func(t *testing.T) {
|
|
client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient())
|
|
deps := BaseDeps{GQLClient: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
cases := []struct {
|
|
name string
|
|
args map[string]any
|
|
want string
|
|
}{
|
|
{
|
|
"end before start",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(10), "end_line": float64(5)},
|
|
"end_line must be >= start_line when both are provided",
|
|
},
|
|
{
|
|
"start line zero",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(0)},
|
|
"start_line must be omitted or >= 1",
|
|
},
|
|
{
|
|
"end line zero",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "end_line": float64(0)},
|
|
"end_line must be omitted or >= 1",
|
|
},
|
|
{
|
|
"page not supported",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(1)},
|
|
"cursor-based pagination",
|
|
},
|
|
{
|
|
"invalid after cursor",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "after": "not-a-cursor"},
|
|
"after cursor is invalid",
|
|
},
|
|
{
|
|
"perPage too large",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(101)},
|
|
"perPage must be between 1 and 100 when provided",
|
|
},
|
|
{
|
|
"perPage zero",
|
|
map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(0)},
|
|
"perPage must be between 1 and 100 when provided",
|
|
},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
req := createMCPRequest(c.args)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &req)
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
assert.Contains(t, getErrorResult(t, result).Text, c.want)
|
|
})
|
|
}
|
|
})
|
|
|
|
// Truncation: hand-build a response with > maxBlameRanges to verify
|
|
// the cap is applied and surfaced.
|
|
t.Run("truncation at maxBlameRanges", func(t *testing.T) {
|
|
ranges := make([]map[string]any, 0, maxBlameRanges+5)
|
|
for i := range maxBlameRanges + 5 {
|
|
ranges = append(ranges, map[string]any{
|
|
"startingLine": i + 1, "endingLine": i + 1, "age": 0,
|
|
"commit": map[string]any{
|
|
"oid": "sha-shared",
|
|
"message": "shared",
|
|
"committedDate": "2024-01-01T00:00:00Z",
|
|
"author": map[string]any{"name": "n", "email": "e@x", "user": nil},
|
|
},
|
|
})
|
|
}
|
|
mocked := githubv4mock.NewMockedHTTPClient(
|
|
githubv4mock.NewQueryMatcher(
|
|
blameQueryShape{},
|
|
makeBlameVars("o", "r", "HEAD", "huge.txt"),
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"repository": map[string]any{
|
|
"defaultBranchRef": map[string]any{"name": "main"},
|
|
"object": map[string]any{
|
|
"__typename": "Commit",
|
|
"blame": map[string]any{"ranges": ranges},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
)
|
|
// Use a large perPage so the truncated set is observable on a
|
|
// single page.
|
|
req := createMCPRequest(map[string]any{
|
|
"owner": "o", "repo": "r", "path": "huge.txt", "perPage": float64(100),
|
|
})
|
|
client := githubv4.NewClient(mocked)
|
|
deps := BaseDeps{GQLClient: client}
|
|
handler := serverTool.Handler(deps)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &req)
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
|
|
var br BlameResult
|
|
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &br))
|
|
assert.True(t, br.Truncated, "truncation flag must be set")
|
|
assert.Equal(t, maxBlameRanges+5, br.TotalRanges)
|
|
assert.Len(t, br.Ranges, 100, "perPage limits the page size")
|
|
assert.True(t, br.PageInfo.HasNextPage)
|
|
assert.NotEmpty(t, br.PageInfo.EndCursor)
|
|
})
|
|
}
|
|
|
|
func Test_ListRepositoryCollaborators(t *testing.T) {
|
|
// Verify tool definition once
|
|
serverTool := ListRepositoryCollaborators(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, "list_repository_collaborators", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.True(t, tool.Annotations.ReadOnlyHint)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "affiliation")
|
|
assert.Contains(t, schema.Properties, "page")
|
|
assert.Contains(t, schema.Properties, "perPage")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"})
|
|
|
|
mockCollaborators := []*github.User{
|
|
{
|
|
Login: github.Ptr("user1"),
|
|
ID: github.Ptr(int64(101)),
|
|
RoleName: github.Ptr("admin"),
|
|
},
|
|
{
|
|
Login: github.Ptr("user2"),
|
|
ID: github.Ptr(int64(102)),
|
|
RoleName: github.Ptr("write"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args map[string]any
|
|
mockResponses []MockBackendOption
|
|
errContains string
|
|
}{
|
|
{
|
|
name: "success",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
mockResponses: []MockBackendOption{
|
|
WithRequestMatch(
|
|
ListCollaborators,
|
|
mockCollaborators,
|
|
),
|
|
},
|
|
},
|
|
{
|
|
name: "success with affiliation filter",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"affiliation": "direct",
|
|
},
|
|
mockResponses: []MockBackendOption{
|
|
WithRequestMatch(
|
|
ListCollaborators,
|
|
mockCollaborators,
|
|
),
|
|
},
|
|
},
|
|
{
|
|
name: "missing owner",
|
|
args: map[string]any{
|
|
"repo": "repo",
|
|
},
|
|
mockResponses: []MockBackendOption{},
|
|
errContains: "missing required parameter: owner",
|
|
},
|
|
{
|
|
name: "missing repo",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
},
|
|
mockResponses: []MockBackendOption{},
|
|
errContains: "missing required parameter: repo",
|
|
},
|
|
{
|
|
name: "empty collaborators returns empty array",
|
|
args: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
},
|
|
mockResponses: []MockBackendOption{
|
|
WithRequestMatch(
|
|
ListCollaborators,
|
|
[]*github.User{},
|
|
),
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...))
|
|
deps := BaseDeps{
|
|
Client: mockClient,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tt.args)
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
|
|
if tt.errContains != "" {
|
|
textContent := getTextResult(t, result)
|
|
assert.Contains(t, textContent.Text, tt.errContains)
|
|
return
|
|
}
|
|
|
|
textContent := getTextResult(t, result)
|
|
require.NotEmpty(t, textContent.Text)
|
|
|
|
var response struct {
|
|
Items []MinimalCollaborator `json:"items"`
|
|
NextPage int `json:"nextPage"`
|
|
PrevPage int `json:"prevPage"`
|
|
FirstPage int `json:"firstPage"`
|
|
LastPage int `json:"lastPage"`
|
|
}
|
|
err = json.Unmarshal([]byte(textContent.Text), &response)
|
|
require.NoError(t, err)
|
|
|
|
if tt.name == "empty collaborators returns empty array" {
|
|
assert.Empty(t, response.Items)
|
|
return
|
|
}
|
|
|
|
collaborators := response.Items
|
|
assert.Len(t, collaborators, 2)
|
|
assert.Equal(t, "user1", collaborators[0].Login)
|
|
assert.Equal(t, int64(101), collaborators[0].ID)
|
|
assert.Equal(t, "admin", collaborators[0].RoleName)
|
|
assert.Equal(t, "user2", collaborators[1].Login)
|
|
assert.Equal(t, int64(102), collaborators[1].ID)
|
|
assert.Equal(t, "write", collaborators[1].RoleName)
|
|
})
|
|
}
|
|
}
|