chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/github/github-mcp-server/pkg/inventory"
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCSVOutputAppliedToDefaultListTools(t *testing.T) {
|
||||
listTool := testCSVOutputTool("list_things", `[{"number":1}]`)
|
||||
getTool := testCSVOutputTool("get_thing", `{"number":1}`)
|
||||
|
||||
tools := withCSVOutput([]inventory.ServerTool{listTool, getTool})
|
||||
require.Len(t, tools, 2)
|
||||
|
||||
// CSV mode does not introduce variants or change tool gating; both tools
|
||||
// remain visible regardless of feature flag state.
|
||||
for _, csvOutputEnabled := range []bool{false, true} {
|
||||
inv := buildCSVOutputInventory(t, tools, csvOutputEnabled)
|
||||
available := inv.AvailableTools(context.Background())
|
||||
require.Len(t, available, 2)
|
||||
|
||||
listing := requireToolByName(t, available, "list_things")
|
||||
assert.Empty(t, listing.FeatureFlagEnable)
|
||||
assert.Empty(t, listing.FeatureFlagDisable)
|
||||
|
||||
getting := requireToolByName(t, available, "get_thing")
|
||||
assert.Empty(t, getting.FeatureFlagEnable)
|
||||
assert.Empty(t, getting.FeatureFlagDisable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) {
|
||||
enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`)
|
||||
enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame
|
||||
disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`)
|
||||
disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame}
|
||||
|
||||
tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly})
|
||||
require.Len(t, tools, 2)
|
||||
|
||||
// Both flag-gated variants get the CSV wrapper; the per-request flag filter
|
||||
// decides which one actually registers, and the runtime csv_output check
|
||||
// decides whether the wrapper converts the response.
|
||||
deps := newCSVOutputTestDeps(true)
|
||||
for _, tool := range tools {
|
||||
result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, textResult(t, result), "number\n")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) {
|
||||
nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions)
|
||||
|
||||
tools := withCSVOutput([]inventory.ServerTool{nonDefaultListTool})
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
// Non-default toolset list tools are not wrapped: even with the flag on,
|
||||
// the response stays in JSON form.
|
||||
deps := newCSVOutputTestDeps(true)
|
||||
result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `[{"number":1}]`, textResult(t, result))
|
||||
}
|
||||
|
||||
func TestCSVOutputDoesNotExposeFormatParameter(t *testing.T) {
|
||||
tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", `[{"number":1}]`)})
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
schema, ok := tools[0].Tool.InputSchema.(*jsonschema.Schema)
|
||||
require.True(t, ok)
|
||||
assert.NotContains(t, schema.Properties, "output_format")
|
||||
}
|
||||
|
||||
func TestCSVOutputConvertsJSONTextToCSVWhenFlagOn(t *testing.T) {
|
||||
tools := withCSVOutput([]inventory.ServerTool{
|
||||
testCSVOutputTool("list_things", `[
|
||||
{
|
||||
"number": 1,
|
||||
"body": "first line\n\tsecond line",
|
||||
"labels": ["bug", "help wanted"],
|
||||
"user": {"login": "octocat"}
|
||||
}
|
||||
]`),
|
||||
})
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
deps := newCSVOutputTestDeps(true)
|
||||
result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.IsError)
|
||||
|
||||
assert.NotContains(t, textResult(t, result), "#")
|
||||
|
||||
records := readCSVResult(t, result)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "first line second line", row["body"])
|
||||
assert.Equal(t, "bug;help wanted", row["labels"])
|
||||
assert.Equal(t, "1", row["number"])
|
||||
assert.Equal(t, "octocat", row["user.login"])
|
||||
}
|
||||
|
||||
func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) {
|
||||
const jsonResponse = `[{"number":1,"user":{"login":"octocat"}}]`
|
||||
tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", jsonResponse)})
|
||||
require.Len(t, tools, 1)
|
||||
|
||||
deps := newCSVOutputTestDeps(false)
|
||||
result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, result.Content, 1)
|
||||
text, ok := result.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
assert.JSONEq(t, jsonResponse, text.Text)
|
||||
}
|
||||
|
||||
func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"issues": [
|
||||
{"number": 1, "title": "First issue"}
|
||||
],
|
||||
"pageInfo": {
|
||||
"endCursor": "cursor-1",
|
||||
"hasNextPage": true
|
||||
},
|
||||
"totalCount": 2
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, csvText, "# pageInfo.endCursor: cursor-1\n")
|
||||
assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n")
|
||||
assert.Contains(t, csvText, "# totalCount: 2\n\n")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "1", row["number"])
|
||||
assert.Equal(t, "First issue", row["title"])
|
||||
assert.NotContains(t, row, "pageInfo.endCursor")
|
||||
assert.NotContains(t, row, "totalCount")
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"discussions": [
|
||||
{
|
||||
"number": 5,
|
||||
"title": "Discussion tools testing",
|
||||
"category": {"name": "Q&A"},
|
||||
"user": {"login": "octocat"}
|
||||
}
|
||||
]
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "Q&A", row["category.name"])
|
||||
assert.Equal(t, "5", row["number"])
|
||||
assert.Equal(t, "Discussion tools testing", row["title"])
|
||||
assert.Equal(t, "octocat", row["user.login"])
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVFindsPrimaryRowsOneLevelDeeper(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"issues": {
|
||||
"nodes": [
|
||||
{"number": 5, "title": "Nested issue"}
|
||||
],
|
||||
"pageInfo": {"hasNextPage": false},
|
||||
"totalCount": 1
|
||||
}
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, csvText, "# issues.pageInfo.hasNextPage: false\n")
|
||||
assert.Contains(t, csvText, "# issues.totalCount: 1\n\n")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "5", row["number"])
|
||||
assert.Equal(t, "Nested issue", row["title"])
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVUsesSingleArrayAsPrimaryRows(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"results": [
|
||||
{"number": 1, "title": "Single array result"}
|
||||
],
|
||||
"pageInfo": {"hasNextPage": true}
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n\n")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "1", row["number"])
|
||||
assert.Equal(t, "Single array result", row["title"])
|
||||
assert.NotContains(t, row, "pageInfo.hasNextPage")
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVFlattensRootObjectWithoutPrimaryRows(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"name": "summary",
|
||||
"pageInfo": {"hasNextPage": false},
|
||||
"totalCount": 2
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, csvText, "#")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "summary", row["name"])
|
||||
assert.Equal(t, "false", row["pageInfo.hasNextPage"])
|
||||
assert.Equal(t, "2", row["totalCount"])
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVConvertsScalarToValueRow(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`"plain value"`)
|
||||
require.NoError(t, err)
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "plain value", row["value"])
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVReturnsEmptyForEmptyArray(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`[]`)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, csvText)
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVReturnsEmptyForEmptyObject(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{}`)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, csvText)
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVReturnsEmptyForOnlyEmptyNestedObjects(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"repository": {
|
||||
"owner": {}
|
||||
}
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, csvText)
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVReturnsMetadataOnlyWhenRowsHaveNoColumns(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"items": [
|
||||
{}
|
||||
],
|
||||
"totalCount": 1
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "# totalCount: 1\n\n", csvText)
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVFlattensAmbiguousArraysAsSingleRow(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"foo": ["a", "b"],
|
||||
"bar": ["c"]
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, csvText, "#")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "c", row["bar"])
|
||||
assert.Equal(t, "a;b", row["foo"])
|
||||
}
|
||||
|
||||
func TestJSONTextToCSVUsesPreferredArrayWhenMultipleArraysExist(t *testing.T) {
|
||||
csvText, err := jsonTextToCSV(`{
|
||||
"items": [
|
||||
{"id": 1}
|
||||
],
|
||||
"other": [
|
||||
{"id": 2}
|
||||
],
|
||||
"totalCount": 1
|
||||
}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, csvText, "# other: {\"id\":2}\n")
|
||||
assert.Contains(t, csvText, "# totalCount: 1\n\n")
|
||||
|
||||
records := readCSVText(t, csvText)
|
||||
require.Len(t, records, 2)
|
||||
|
||||
row := csvRow(t, records[0], records[1])
|
||||
assert.Equal(t, "1", row["id"])
|
||||
}
|
||||
|
||||
func testCSVOutputTool(name string, response string) inventory.ServerTool {
|
||||
return testCSVOutputToolWithToolset(name, response, ToolsetMetadataRepos)
|
||||
}
|
||||
|
||||
func testCSVOutputToolWithToolset(name string, response string, toolset inventory.ToolsetMetadata) inventory.ServerTool {
|
||||
return inventory.ServerTool{
|
||||
Tool: mcp.Tool{
|
||||
Name: name,
|
||||
Annotations: &mcp.ToolAnnotations{
|
||||
ReadOnlyHint: true,
|
||||
},
|
||||
InputSchema: &jsonschema.Schema{
|
||||
Type: "object",
|
||||
Properties: map[string]*jsonschema.Schema{},
|
||||
},
|
||||
},
|
||||
Toolset: toolset,
|
||||
HandlerFunc: func(_ any) mcp.ToolHandler {
|
||||
return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
return &mcp.CallToolResult{
|
||||
Content: []mcp.Content{
|
||||
&mcp.TextContent{Text: response},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func buildCSVOutputInventory(t *testing.T, tools []inventory.ServerTool, _ bool) *inventory.Inventory {
|
||||
t.Helper()
|
||||
|
||||
inv, err := inventory.NewBuilder().
|
||||
SetTools(tools).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
return inv
|
||||
}
|
||||
|
||||
func newCSVOutputTestDeps(csvOutputEnabled bool) ToolDependencies {
|
||||
return csvOutputTestDeps{stubDeps: stubDeps{obsv: stubExporters()}, csvOn: csvOutputEnabled}
|
||||
}
|
||||
|
||||
type csvOutputTestDeps struct {
|
||||
stubDeps
|
||||
csvOn bool
|
||||
}
|
||||
|
||||
func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool {
|
||||
return flag == FeatureFlagCSVOutput && d.csvOn
|
||||
}
|
||||
|
||||
func requireToolByName(t *testing.T, tools []inventory.ServerTool, name string) inventory.ServerTool {
|
||||
t.Helper()
|
||||
|
||||
for _, tool := range tools {
|
||||
if tool.Tool.Name == name {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
require.Failf(t, "tool not found", "tool %q not found", name)
|
||||
return inventory.ServerTool{}
|
||||
}
|
||||
|
||||
func testCSVOutputRequest() *mcp.CallToolRequest {
|
||||
return &mcp.CallToolRequest{
|
||||
Params: &mcp.CallToolParamsRaw{
|
||||
Arguments: json.RawMessage(`{}`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readCSVResult(t *testing.T, result *mcp.CallToolResult) [][]string {
|
||||
t.Helper()
|
||||
|
||||
require.Len(t, result.Content, 1)
|
||||
text, ok := result.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
|
||||
return readCSVText(t, text.Text)
|
||||
}
|
||||
|
||||
func textResult(t *testing.T, result *mcp.CallToolResult) string {
|
||||
t.Helper()
|
||||
|
||||
require.Len(t, result.Content, 1)
|
||||
text, ok := result.Content[0].(*mcp.TextContent)
|
||||
require.True(t, ok)
|
||||
return text.Text
|
||||
}
|
||||
|
||||
func readCSVText(t *testing.T, text string) [][]string {
|
||||
t.Helper()
|
||||
|
||||
reader := csv.NewReader(strings.NewReader(text))
|
||||
reader.Comment = '#'
|
||||
records, err := reader.ReadAll()
|
||||
require.NoError(t, err)
|
||||
return records
|
||||
}
|
||||
|
||||
func csvRow(t *testing.T, headers []string, record []string) map[string]string {
|
||||
t.Helper()
|
||||
require.Len(t, record, len(headers))
|
||||
|
||||
row := make(map[string]string, len(headers))
|
||||
for i, header := range headers {
|
||||
row[header] = record[i]
|
||||
}
|
||||
return row
|
||||
}
|
||||
Reference in New Issue
Block a user