Files
github--github-mcp-server/pkg/github/issues_multiselect_test.go
Copilot a589920d1a
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
Merge main; remove multi-select feature flag
Resolves conflicts from merging origin/main into the multi-select branch.
main added the `fields_param` feature which also split `list_issues` into
flag-gated variants, colliding with this branch's
`remote_mcp_issue_fields_multiselect` split.

Rather than gate `list_issues` on two independent flags (which the inventory's
single FeatureFlagEnable can't express for the "both on" case), the multi-select
feature flag is removed entirely — multi-select is now unconditional, matching
the already-unconditional read paths:

- issue_write / list_issue_fields collapse to their single multi-select-aware
  variant; the legacy siblings and issues_legacy_multiselect.go are deleted
- list_issues adopts main's fields_param split (ListIssues / LegacyListIssues)
  with the multi-select `values` slot always advertised
- optionalIssueWriteFields / resolveIssueRequestFieldValues / parseRawFieldFilters
  drop the now-constant multiSelectEnabled bool and their dead single-select-only
  branches
- FeatureFlagIssueFieldsMultiSelect const, allowlist and insiders entries removed
- toolsnaps + generated docs regenerated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-13 13:28:02 +01:00

414 lines
12 KiB
Go

package github
import (
"context"
"testing"
"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// multi_select fixture reused across these tests.
func multiSelectField() IssueField {
return IssueField{
ID: "IFMS_1",
Name: "Components",
DataType: "MULTI_SELECT",
Options: []IssueSingleSelectFieldOption{
{ID: "OPT_AUTH", Name: "Auth", Color: "red"},
{ID: "OPT_BILL", Name: "Billing", Color: "yellow"},
{ID: "OPT_API", Name: "API", Color: "blue"},
},
}
}
func Test_parseRawFieldFilters_MultiSelect(t *testing.T) {
t.Parallel()
t.Run("accepts values as []any of strings", func(t *testing.T) {
filters, err := parseRawFieldFilters(map[string]any{
"field_filters": []any{
map[string]any{
"field_name": "Components",
"values": []any{"Auth", "Billing"},
},
},
})
require.NoError(t, err)
require.Len(t, filters, 1)
assert.Equal(t, "Components", filters[0].Name)
assert.False(t, filters[0].HasValue)
assert.True(t, filters[0].HasValues)
assert.Equal(t, []string{"Auth", "Billing"}, filters[0].Values)
})
t.Run("accepts values as []string", func(t *testing.T) {
filters, err := parseRawFieldFilters(map[string]any{
"field_filters": []map[string]any{
{"field_name": "Components", "values": []string{"Auth"}},
},
})
require.NoError(t, err)
require.Len(t, filters, 1)
assert.Equal(t, []string{"Auth"}, filters[0].Values)
})
t.Run("rejects both value and values on the same entry", func(t *testing.T) {
_, err := parseRawFieldFilters(map[string]any{
"field_filters": []any{
map[string]any{
"field_name": "Components",
"value": "Auth",
"values": []any{"Auth"},
},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "provide either 'value' or 'values', not both")
})
t.Run("rejects entry with neither value nor values", func(t *testing.T) {
_, err := parseRawFieldFilters(map[string]any{
"field_filters": []any{
map[string]any{"field_name": "Components"},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "missing 'value'")
})
t.Run("rejects values items that are not strings", func(t *testing.T) {
_, err := parseRawFieldFilters(map[string]any{
"field_filters": []any{
map[string]any{
"field_name": "Components",
"values": []any{"Auth", 7},
},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "values must be an array of strings")
})
}
func Test_resolveFieldFilters_MultiSelect(t *testing.T) {
t.Parallel()
fields := []IssueField{multiSelectField()}
t.Run("matches options case-insensitively and sets MultiSelectOptionValues", func(t *testing.T) {
raw := []rawFieldFilter{{
Name: "Components",
Values: []string{"auth", "BILLING"},
HasValues: true,
}}
out, err := resolveFieldFilters(raw, fields)
require.NoError(t, err)
require.Len(t, out, 1)
require.NotNil(t, out[0].MultiSelectOptionValues)
got := *out[0].MultiSelectOptionValues
assert.Equal(t, []githubv4.String{"Auth", "Billing"}, got)
assert.Nil(t, out[0].SingleSelectOptionValue)
})
t.Run("trims surrounding whitespace when matching options", func(t *testing.T) {
raw := []rawFieldFilter{{
Name: "Components",
Values: []string{" auth ", " Billing"},
HasValues: true,
}}
out, err := resolveFieldFilters(raw, fields)
require.NoError(t, err)
require.Len(t, out, 1)
require.NotNil(t, out[0].MultiSelectOptionValues)
assert.Equal(t, []githubv4.String{"Auth", "Billing"}, *out[0].MultiSelectOptionValues)
})
t.Run("rejects unknown option and lists valid ones", func(t *testing.T) {
raw := []rawFieldFilter{{
Name: "Components",
Values: []string{"Auth", "Nope"},
HasValues: true,
}}
_, err := resolveFieldFilters(raw, fields)
require.Error(t, err)
assert.Contains(t, err.Error(), "\"Nope\" is not a valid option")
assert.Contains(t, err.Error(), "Auth, Billing, API")
})
t.Run("rejects scalar value on multi_select field", func(t *testing.T) {
raw := []rawFieldFilter{{
Name: "Components",
Value: "Auth",
HasValue: true,
}}
_, err := resolveFieldFilters(raw, fields)
require.Error(t, err)
assert.Contains(t, err.Error(), "is multi_select, use 'values'")
})
t.Run("rejects empty values slice", func(t *testing.T) {
raw := []rawFieldFilter{{
Name: "Components",
Values: []string{},
HasValues: true,
}}
_, err := resolveFieldFilters(raw, fields)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires at least one value")
})
t.Run("rejects values array on single_select field", func(t *testing.T) {
ssFields := []IssueField{{
Name: "Priority",
DataType: "SINGLE_SELECT",
Options: []IssueSingleSelectFieldOption{{Name: "P1"}, {Name: "P2"}},
}}
raw := []rawFieldFilter{{
Name: "Priority",
Values: []string{"P1"},
HasValues: true,
}}
_, err := resolveFieldFilters(raw, ssFields)
require.Error(t, err)
assert.Contains(t, err.Error(), "is single_select, use 'value'")
})
}
func Test_fragmentToMinimalFieldValue_MultiSelect(t *testing.T) {
t.Parallel()
fv := IssueFieldValueFragment{
TypeName: "IssueFieldMultiSelectValue",
}
fv.MultiSelectValue.Field.MultiSelect.Name = "Components"
fv.MultiSelectValue.Field.MultiSelect.FullDatabaseID = "42"
fv.MultiSelectValue.Options = []struct {
Name githubv4.String
}{
{Name: "Auth"},
{Name: "Billing"},
}
m, ok := fragmentToMinimalFieldValue(fv)
require.True(t, ok)
assert.Equal(t, "Components", m.Field)
assert.Equal(t, []string{"Auth", "Billing"}, m.Values)
assert.Empty(t, m.Value)
}
func Test_IssueFieldRef_Name_MultiSelect(t *testing.T) {
t.Parallel()
var ref IssueFieldRef
ref.MultiSelect.Name = "Components"
ref.MultiSelect.FullDatabaseID = "99"
assert.Equal(t, "Components", ref.Name())
assert.Equal(t, "99", ref.FullDatabaseIDStr())
}
func Test_optionalIssueWriteFields_MultiSelect(t *testing.T) {
t.Parallel()
t.Run("accepts field_option_names as []any of strings", func(t *testing.T) {
fields, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"field_option_names": []any{"Auth", "Billing"},
},
},
})
require.NoError(t, err)
require.Len(t, fields, 1)
assert.Equal(t, "Components", fields[0].FieldName)
assert.Equal(t, []string{"Auth", "Billing"}, fields[0].FieldOptionNames)
assert.Empty(t, fields[0].FieldOptionName)
assert.Nil(t, fields[0].Value)
})
t.Run("accepts field_option_names as []string", func(t *testing.T) {
fields, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"field_option_names": []string{"Auth"},
},
},
})
require.NoError(t, err)
require.Len(t, fields, 1)
assert.Equal(t, []string{"Auth"}, fields[0].FieldOptionNames)
})
t.Run("rejects empty field_option_names slice", func(t *testing.T) {
_, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"field_option_names": []any{},
},
},
})
require.Error(t, err)
// An empty slice is a common "clear the field" guess; nudge callers to delete:true
// so the GraphQL deletion mutation runs instead of an unintentional no-op.
assert.Contains(t, err.Error(), "empty field_option_names")
assert.Contains(t, err.Error(), "delete: true")
})
t.Run("rejects field_option_names + value combo", func(t *testing.T) {
_, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"value": "Auth",
"field_option_names": []any{"Auth"},
},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot specify more than one of value, field_option_name, or field_option_names")
})
t.Run("rejects field_option_names + field_option_name combo", func(t *testing.T) {
_, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"field_option_name": "Auth",
"field_option_names": []any{"Auth"},
},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot specify more than one of")
})
t.Run("rejects field_option_names + delete combo", func(t *testing.T) {
_, err := optionalIssueWriteFields(map[string]any{
"issue_fields": []any{
map[string]any{
"field_name": "Components",
"delete": true,
"field_option_names": []any{"Auth"},
},
},
})
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot specify 'delete' together with")
})
}
func Test_resolveIssueRequestFieldValues_MultiSelect(t *testing.T) {
t.Parallel()
// Mocked metadata GraphQL response — one IssueFieldMultiSelect field with three options.
metaResponse := githubv4mock.DataResponse(map[string]any{
"repository": map[string]any{
"issueFields": map[string]any{
"nodes": []any{
map[string]any{
"__typename": "IssueFieldMultiSelect",
"fullDatabaseId": "101",
"name": "Components",
"dataType": "multi_select",
"options": []any{
map[string]any{"fullDatabaseId": "9001", "name": "Auth"},
map[string]any{"fullDatabaseId": "9002", "name": "Billing"},
map[string]any{"fullDatabaseId": "9003", "name": "API"},
},
},
},
},
},
})
newClient := func() *githubv4.Client {
return githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
issueFieldWriteMetadataQuery{},
map[string]any{
"owner": githubv4.String("owner"),
"repo": githubv4.String("repo"),
},
metaResponse,
),
))
}
t.Run("resolves option names to []string payload", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
FieldOptionNames: []string{"Auth", "Billing"},
}}
vals, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.NoError(t, err)
require.Len(t, vals, 1)
require.NotNil(t, vals[0].Value)
// REST IssueField#build_value_attributes expects an array of option names for multi_select.
got, ok := vals[0].Value.([]string)
require.True(t, ok, "value should be []string, got %T", vals[0].Value)
assert.Equal(t, []string{"Auth", "Billing"}, got)
})
t.Run("matches options case-insensitively and returns canonical names", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
FieldOptionNames: []string{"auth", "BILLING"},
}}
vals, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.NoError(t, err)
got, ok := vals[0].Value.([]string)
require.True(t, ok)
assert.Equal(t, []string{"Auth", "Billing"}, got)
})
t.Run("rejects unknown option name and lists valid ones", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
FieldOptionNames: []string{"Auth", "Nope"},
}}
_, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.Error(t, err)
assert.Contains(t, err.Error(), "Nope")
})
t.Run("rejects scalar field_option_name on multi_select field", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
FieldOptionName: "Auth",
}}
_, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.Error(t, err)
assert.Contains(t, err.Error(), "field_option_name cannot be used")
assert.Contains(t, err.Error(), "field_option_names")
})
t.Run("rejects raw value on multi_select field", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
Value: "Auth,Billing",
}}
_, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.Error(t, err)
assert.Contains(t, err.Error(), "multi_select")
})
t.Run("captures node ID for delete:true so the GraphQL mutation can clear the field", func(t *testing.T) {
in := []issueWriteFieldInput{{
FieldName: "Components",
Delete: true,
}}
vals, deletions, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", in)
require.NoError(t, err)
assert.Empty(t, vals)
require.Len(t, deletions, 1)
assert.Equal(t, int64(101), deletions[0],
"the resolver returns the database field ID; the REST DELETE endpoint takes this integer in its URL path")
})
}