diff --git a/README.md b/README.md index 432541f434..67f1cee8cd 100644 --- a/README.md +++ b/README.md @@ -1024,7 +1024,7 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Required when state_reason is 'duplicate'. (number, optional) - - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional) + - `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', 'field_option_names', or 'delete: true'. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. @@ -1057,7 +1057,7 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - **OAuth Challenge Scopes**: `repo` - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and either 'value' (text, number, YYYY-MM-DD date, or single-select option name) or 'values' (multi-select option names). For multi-select fields, all listed values must be set on an issue for it to match (AND semantics) — to match any-of, make multiple list_issues calls and union the results. (object[], optional) - `fields`: Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data. (string[], optional) - `labels`: Filter by labels (string[], optional) - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index f3380ae010..01b9e92676 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -196,7 +196,7 @@ as output formatting) won't appear here. - **set_issue_fields** - Set Issue Fields - **OAuth Challenge Scopes**: `repo` - - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) + - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, 'single_select_option_id' (the GraphQL node ID of the option) for single select fields, or 'multi_select_option_ids' (an array of GraphQL node IDs) for multi select fields. Set 'delete' to true to remove a field value. (object[], required) - `issue_number`: The issue number to update (number, required) - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index dde73657f6..939fb9ac66 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -32,12 +32,12 @@ "type": "number" }, "issue_fields": { - "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + "description": "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', 'field_option_names', or 'delete: true'.", "items": { "additionalProperties": false, "properties": { "delete": { - "description": "Set to true to clear this field's current value on the issue. When false or omitted, this property is ignored. Cannot be true when 'value' or 'field_option_name' is provided.", + "description": "Set to true to clear this field's current value on the issue. When false or omitted, this property is ignored. Cannot be true when 'value', 'field_option_name', or 'field_option_names' is provided.", "type": "boolean" }, "field_name": { @@ -45,11 +45,18 @@ "type": "string" }, "field_option_name": { - "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value' or 'delete: true'.", + "description": "Option name for single-select fields. Validated against the field's options before the API call. Cannot be combined with 'value', 'field_option_names', or 'delete: true'.", "type": "string" }, + "field_option_names": { + "description": "Option names for multi-select fields. All names are validated against the field's options before the API call. An empty array is rejected — use 'delete: true' to clear the field. Cannot be combined with 'value', 'field_option_name', or 'delete: true'.", + "items": { + "type": "string" + }, + "type": "array" + }, "value": { - "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name' or 'delete: true'.", + "description": "Value to set. Use for text, number, and date fields (date as YYYY-MM-DD). For single-select fields, prefer 'field_option_name' so the option is validated before the API call. Cannot be combined with 'field_option_name', 'field_option_names', or 'delete: true'.", "type": [ "string", "number", diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index f9744d009e..c10aa776ee 100644 --- a/pkg/github/__toolsnaps__/list_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -4,7 +4,7 @@ "readOnlyHint": true, "title": "List issue fields" }, - "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select, multi_select), and for single_select and multi_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", "inputSchema": { "properties": { "owner": { diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 420577b197..abbdf834d7 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -20,7 +20,7 @@ "type": "string" }, "field_filters": { - "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "description": "Filter by custom issue field values. Each entry takes a field_name and either 'value' (text, number, YYYY-MM-DD date, or single-select option name) or 'values' (multi-select option names). For multi-select fields, all listed values must be set on an issue for it to match (AND semantics) — to match any-of, make multiple list_issues calls and union the results.", "items": { "properties": { "field_name": { @@ -28,13 +28,19 @@ "type": "string" }, "value": { - "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "description": "Value to filter on for text, number, date, or single-select fields. For single-select, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value. Cannot be combined with 'values'.", "type": "string" + }, + "values": { + "description": "Option names to filter on for multi-select fields. Matches issues that have ALL of these options set (AND semantics). To match any-of, make multiple list_issues calls. Cannot be combined with 'value'.", + "items": { + "type": "string" + }, + "type": "array" } }, "required": [ - "field_name", - "value" + "field_name" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index 64b2731bea..c94c9ae51a 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -22,7 +22,7 @@ "inputSchema": { "properties": { "fields": { - "description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.", + "description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, 'single_select_option_id' (the GraphQL node ID of the option) for single select fields, or 'multi_select_option_ids' (an array of GraphQL node IDs) for multi select fields. Set 'delete' to true to remove a field value.", "items": { "properties": { "confidence": { @@ -50,6 +50,13 @@ "description": "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the value is applied or recorded as a proposal is determined by the API.", "type": "boolean" }, + "multi_select_option_ids": { + "description": "The GraphQL node IDs of the options to set for a multi select field", + "items": { + "type": "string" + }, + "type": "array" + }, "number_value": { "description": "The value to set for a number field", "type": "number" diff --git a/pkg/github/consolidated_issue_types.go b/pkg/github/consolidated_issue_types.go index ccd84e0173..4a6420bbc0 100644 --- a/pkg/github/consolidated_issue_types.go +++ b/pkg/github/consolidated_issue_types.go @@ -49,10 +49,11 @@ func (input *IssueWriteInput) UnmarshalJSON(raw []byte) error { } type IssueWriteFieldInput struct { - FieldName string `json:"field_name"` - Value *IssueFieldValue `json:"value,omitempty"` - FieldOptionName *string `json:"field_option_name,omitempty"` - Delete *bool `json:"delete,omitempty"` + FieldName string `json:"field_name"` + Value *IssueFieldValue `json:"value,omitempty"` + FieldOptionName *string `json:"field_option_name,omitempty"` + FieldOptionNames []string `json:"field_option_names,omitempty"` + Delete *bool `json:"delete,omitempty"` } // IssueFieldValue represents the REST scalar without losing its JSON type. diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index b54480252f..c5378cfbd7 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -272,7 +272,7 @@ func Test_SearchIssues_FieldsTelemetry(t *testing.T) { // and variables list_issues issues for owner/repo with default parameters (no // labels, no since). They must stay in sync with the query built in // getIssueQueryType; see Test_ListIssues for the canonical copies. -const listIssuesFieldsFieldValuesSelection = "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" +const listIssuesFieldsFieldValuesSelection = "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldMultiSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},options{name}},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" const listIssuesFieldsQuery = "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + listIssuesFieldsFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" diff --git a/pkg/github/granular_issue_types.go b/pkg/github/granular_issue_types.go index c0b9f2e16d..758b446e58 100644 --- a/pkg/github/granular_issue_types.go +++ b/pkg/github/granular_issue_types.go @@ -153,6 +153,7 @@ type GranularIssueField struct { NumberValue *float64 `json:"number_value,omitempty"` DateValue string `json:"date_value,omitempty"` SingleSelectOptionID string `json:"single_select_option_id,omitempty"` + MultiSelectOptionIDs []string `json:"multi_select_option_ids,omitempty"` Delete bool `json:"delete,omitempty"` Rationale string `json:"rationale,omitempty"` Confidence string `json:"confidence,omitempty"` @@ -298,6 +299,12 @@ func granularIssueFieldsFromMutation(fields []IssueFieldCreateOrUpdateInput) []G if field.SingleSelectOptionID != nil { entry.SingleSelectOptionID = (*field.SingleSelectOptionID).(string) } + if field.MultiSelectOptionIDs != nil { + entry.MultiSelectOptionIDs = make([]string, len(*field.MultiSelectOptionIDs)) + for i, optionID := range *field.MultiSelectOptionIDs { + entry.MultiSelectOptionIDs[i] = optionID.(string) + } + } if field.Delete != nil { entry.Delete = bool(*field.Delete) } @@ -333,6 +340,13 @@ func granularIssueFieldMutation(fields []GranularIssueField) []IssueFieldCreateO value := githubv4.ID(field.SingleSelectOptionID) entry.SingleSelectOptionID = &value } + if len(field.MultiSelectOptionIDs) > 0 { + optionIDs := make([]githubv4.ID, len(field.MultiSelectOptionIDs)) + for i, optionID := range field.MultiSelectOptionIDs { + optionIDs[i] = githubv4.ID(optionID) + } + entry.MultiSelectOptionIDs = &optionIDs + } if field.Delete { value := githubv4.Boolean(true) entry.Delete = &value @@ -920,6 +934,20 @@ func normalizeGranularSetIssueFieldsArguments(raw json.RawMessage) (json.RawMess input.SingleSelectOptionID = &optionID valueCount++ } + if rawIDs, exists := fieldMap["multi_select_option_ids"]; exists && rawIDs != nil { + ids, err := parseStringSlice(rawIDs) + if err != nil { + return nil, fmt.Errorf("multi_select_option_ids must be an array of strings") + } + if len(ids) > 0 { + optionIDs := make([]githubv4.ID, len(ids)) + for i, optionID := range ids { + optionIDs[i] = githubv4.ID(optionID) + } + input.MultiSelectOptionIDs = &optionIDs + valueCount++ + } + } if _, exists := fieldMap["delete"]; exists { del, err := OptionalParam[bool](fieldMap, "delete") if err == nil && del { @@ -930,10 +958,10 @@ func normalizeGranularSetIssueFieldsArguments(raw json.RawMessage) (json.RawMess } if valueCount == 0 { - return nil, fmt.Errorf("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true") + return nil, fmt.Errorf("each field must have a value (text_value, number_value, date_value, single_select_option_id, multi_select_option_ids) or delete: true") } if valueCount > 1 { - return nil, fmt.Errorf("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided") + return nil, fmt.Errorf("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id, multi_select_option_ids) or delete: true, but multiple were provided") } if _, exists := fieldMap["rationale"]; exists { diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 460a192766..dab91e724c 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -1891,6 +1891,87 @@ func TestGranularSetIssueFields(t *testing.T) { assert.False(t, result.IsError) }) + t.Run("successful set with multi select option ids", func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + setIssueFieldValueMutation{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + MultiSelectOptionIDs: &[]githubv4.ID{githubv4.ID("OPT_1"), githubv4.ID("OPT_2")}, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "multi_select_option_ids": []any{"OPT_1", "OPT_2"}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("multi select combined with single select returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "single_select_option_id": "OPT_1", "multi_select_option_ids": []any{"OPT_2"}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "each field must have exactly one value") + }) + t.Run("missing required parameter fields", func(t *testing.T) { deps := BaseDeps{} serverTool := GranularSetIssueFields(translations.NullTranslationHelper) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 9937f74bfd..455a72445b 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -82,6 +82,21 @@ type issueFieldNode struct { Priority *int } } `graphql:"... on IssueFieldSingleSelect"` + IssueFieldMultiSelect struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldMultiSelect"` } // issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository. @@ -108,7 +123,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_fields", - Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select, multi_select), and for single_select and multi_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), ReadOnlyHint: true, @@ -221,6 +236,26 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { Visibility: string(node.IssueFieldSingleSelect.Visibility), Options: opts, } + case "IssueFieldMultiSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldMultiSelect.Options)) + for _, o := range node.IssueFieldMultiSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldMultiSelect.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldMultiSelect.FullDatabaseID)), + Name: string(node.IssueFieldMultiSelect.Name), + Description: string(node.IssueFieldMultiSelect.Description), + DataType: string(node.IssueFieldMultiSelect.DataType), + Visibility: string(node.IssueFieldMultiSelect.Visibility), + Options: opts, + } case "IssueFieldText": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldText.ID), diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index f30cdab256..db3007357a 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -15,10 +15,10 @@ import ( ) func Test_ListIssueFields(t *testing.T) { - // Verify tool definition serverTool := ListIssueFields(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Contains(t, tool.Description, "multi_select", "the description must mention multi_select") assert.Equal(t, "list_issue_fields", tool.Name) assert.NotEmpty(t, tool.Description) @@ -149,6 +149,56 @@ func Test_ListIssueFields(t *testing.T) { }, }, }, + { + name: "multi_select field with options returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldMultiSelect", + "id": "IFMS_1", + "fullDatabaseId": "100", + "name": "Components", + "description": "Affected components", + "dataType": "MULTI_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{ + "id": "OPT_1", + "name": "API", + "color": "blue", + }, + map[string]any{ + "id": "OPT_2", + "name": "Web", + "color": "green", + }, + }, + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFMS_1", + DatabaseID: 100, + Name: "Components", + Description: "Affected components", + DataType: "MULTI_SELECT", + Visibility: "ALL", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_1", Name: "API", Color: "blue"}, + {ID: "OPT_2", Name: "Web", Color: "green"}, + }, + }, + }, + }, { name: "missing owner parameter", requestArgs: map[string]any{ diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 30005854aa..3514a8451a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -43,10 +43,11 @@ type IssueClosedStateReason string // issueWriteFieldInput is a user-friendly issue field input for issue_write. // Field IDs and option IDs are resolved internally before calling the REST API. type issueWriteFieldInput struct { - FieldName string - Value any - FieldOptionName string - Delete bool + FieldName string + Value any + FieldOptionName string + FieldOptionNames []string + Delete bool } const ( @@ -147,6 +148,15 @@ type issueFieldWriteMetadataNode struct { Name githubv4.String } } `graphql:"... on IssueFieldSingleSelect"` + IssueFieldMultiSelect struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + } + } `graphql:"... on IssueFieldMultiSelect"` } type issueFieldWriteMetadataQuery struct { @@ -158,8 +168,8 @@ type issueFieldWriteMetadataQuery struct { } // IssueFieldRef resolves the name of an issue field across its concrete types. -// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, -// so we have to ask for `name` on each member. +// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, +// IssueFieldMultiSelect, IssueFieldText, so we have to ask for `name` on each member. type IssueFieldRef struct { Date struct { Name githubv4.String @@ -173,6 +183,10 @@ type IssueFieldRef struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` } `graphql:"... on IssueFieldSingleSelect"` + MultiSelect struct { + Name githubv4.String + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + } `graphql:"... on IssueFieldMultiSelect"` Text struct { Name githubv4.String FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` @@ -188,6 +202,8 @@ func (r IssueFieldRef) Name() string { return string(r.Number.Name) case r.SingleSelect.Name != "": return string(r.SingleSelect.Name) + case r.MultiSelect.Name != "": + return string(r.MultiSelect.Name) case r.Text.Name != "": return string(r.Text.Name) } @@ -204,6 +220,8 @@ func (r IssueFieldRef) FullDatabaseIDStr() string { return string(r.Number.FullDatabaseID) case r.SingleSelect.FullDatabaseID != "": return string(r.SingleSelect.FullDatabaseID) + case r.MultiSelect.FullDatabaseID != "": + return string(r.MultiSelect.FullDatabaseID) case r.Text.FullDatabaseID != "": return string(r.Text.FullDatabaseID) } @@ -211,8 +229,9 @@ func (r IssueFieldRef) FullDatabaseIDStr() string { } // IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union -// of 4 concrete value types; each carries its own value scalar and a reference to its parent field. +// of concrete value types; each carries its own value scalar and a reference to its parent field. // The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode. +// The MultiSelect variant exposes selected options as a plain list (not a connection). type IssueFieldValueFragment struct { TypeName string `graphql:"__typename"` DateValue struct { @@ -227,12 +246,19 @@ type IssueFieldValueFragment struct { Field IssueFieldRef Value githubv4.String } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Field IssueFieldRef + Options []struct { + Name githubv4.String + } + } `graphql:"... on IssueFieldMultiSelectValue"` TextValue struct { Field IssueFieldRef Value githubv4.String } `graphql:"... on IssueFieldTextValue"` } +// optionalIssueWriteFields parses the `issue_fields` parameter. func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, error) { issueFieldsRaw, exists := args["issue_fields"] if !exists { @@ -267,6 +293,16 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro return nil, err } + var fieldOptionNames []string + _, hasNamesKey := itemMap["field_option_names"] + if rawNames := itemMap["field_option_names"]; hasNamesKey && rawNames != nil { + parsed, err := parseStringSlice(rawNames) + if err != nil { + return nil, fmt.Errorf("field_option_names for field %q must be an array of strings", fieldName) + } + fieldOptionNames = parsed + } + deleteField, err := OptionalParam[bool](itemMap, "delete") if err != nil { return nil, err @@ -277,8 +313,8 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro } if deleteField { - if hasValue || fieldOptionName != "" { - return nil, fmt.Errorf("issue field %q cannot specify 'delete' together with 'value' or 'field_option_name'", fieldName) + if hasValue || fieldOptionName != "" || len(fieldOptionNames) > 0 { + return nil, fmt.Errorf("issue field %q cannot specify 'delete' together with 'value' or 'field_option_name', or 'field_option_names'", fieldName) } issueFields = append(issueFields, issueWriteFieldInput{ FieldName: fieldName, @@ -287,24 +323,41 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro continue } - if hasValue && fieldOptionName != "" { - return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName) + setters := 0 + if hasValue { + setters++ } - - if !hasValue && fieldOptionName == "" { - return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName) + if fieldOptionName != "" { + setters++ + } + if len(fieldOptionNames) > 0 { + setters++ + } + if setters == 0 { + if hasNamesKey { + return nil, fmt.Errorf("issue field %q has empty field_option_names — use 'delete: true' to clear the field", fieldName) + } + return nil, fmt.Errorf("issue field %q must specify either value or field_option_name, or field_option_names", fieldName) + } + if setters > 1 { + return nil, fmt.Errorf("issue field %q cannot specify more than one of value, field_option_name, or field_option_names", fieldName) } issueFields = append(issueFields, issueWriteFieldInput{ - FieldName: fieldName, - Value: value, - FieldOptionName: fieldOptionName, + FieldName: fieldName, + Value: value, + FieldOptionName: fieldOptionName, + FieldOptionNames: fieldOptionNames, }) } return issueFields, nil } +// resolveIssueRequestFieldValues looks up field metadata via GraphQL and turns +// the user-supplied issueWriteFieldInputs into the REST-shaped values + the +// list of field IDs the caller should clear via the dedicated REST DELETE +// endpoint after the PATCH. func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, error) { if len(issueFields) == 0 { return nil, nil, nil @@ -333,6 +386,8 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli name = string(node.IssueFieldDate.Name) case "IssueFieldSingleSelect": name = string(node.IssueFieldSingleSelect.Name) + case "IssueFieldMultiSelect": + name = string(node.IssueFieldMultiSelect.Name) default: continue } @@ -361,6 +416,9 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli case "IssueFieldSingleSelect": fullDatabaseIDStr = string(node.IssueFieldSingleSelect.FullDatabaseID) dataType = string(node.IssueFieldSingleSelect.DataType) + case "IssueFieldMultiSelect": + fullDatabaseIDStr = string(node.IssueFieldMultiSelect.FullDatabaseID) + dataType = string(node.IssueFieldMultiSelect.DataType) } fieldID := parseFullDatabaseID(fullDatabaseIDStr) @@ -374,9 +432,10 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli } resolvedValue := fieldInput.Value - if fieldInput.FieldOptionName != "" { + switch { + case fieldInput.FieldOptionName != "": if !strings.EqualFold(dataType, "single_select") { - return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType) + return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used (use %s)", fieldInput.FieldName, dataType, fieldOptionInputHint(dataType)) } optionFound := false @@ -392,6 +451,33 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli if !optionFound { return nil, nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName) } + case len(fieldInput.FieldOptionNames) > 0: + if !strings.EqualFold(dataType, "multi_select") { + return nil, nil, fmt.Errorf("issue field %q is %q, so field_option_names cannot be used (use %s)", fieldInput.FieldName, dataType, fieldOptionInputHint(dataType)) + } + + resolvedNames := make([]string, 0, len(fieldInput.FieldOptionNames)) + for _, requested := range fieldInput.FieldOptionNames { + matched := "" + for _, option := range node.IssueFieldMultiSelect.Options { + if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(requested)) { + matched = string(option.Name) + break + } + } + if matched == "" { + return nil, nil, fmt.Errorf("issue field option %q was not found for field %q", requested, fieldInput.FieldName) + } + resolvedNames = append(resolvedNames, matched) + } + // REST IssueField#build_value_attributes for multi_select expects an array of option names. + resolvedValue = resolvedNames + default: + // multi_select must go through field_option_names (which validates options); + // reject a raw value here so callers get a clear error instead of a REST 422. + if strings.EqualFold(dataType, "multi_select") { + return nil, nil, fmt.Errorf("issue field %q is multi_select, use field_option_names to set its value", fieldInput.FieldName) + } } resolved = append(resolved, &github.IssueRequestFieldValue{ @@ -403,6 +489,19 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli return resolved, fieldIDsToDelete, nil } +// fieldOptionInputHint returns the user-facing input slot name that matches a select field's data type. +// Used in error messages so callers know which slot to switch to. +func fieldOptionInputHint(dataType string) string { + switch { + case strings.EqualFold(dataType, "single_select"): + return "field_option_name" + case strings.EqualFold(dataType, "multi_select"): + return "field_option_names" + default: + return "value" + } +} + // fetchExistingIssueFieldValues retrieves the current field values for an issue // as IssueRequestFieldValue entries, ready to be merged before an update. func fetchExistingIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueNumber int) ([]*github.IssueRequestFieldValue, error) { @@ -443,6 +542,13 @@ func fetchExistingIssueFieldValues(ctx context.Context, gqlClient *githubv4.Clie case "IssueFieldSingleSelectValue": fieldIDStr = node.SingleSelectValue.Field.FullDatabaseIDStr() value = string(node.SingleSelectValue.Value) + case "IssueFieldMultiSelectValue": + fieldIDStr = node.MultiSelectValue.Field.FullDatabaseIDStr() + names := make([]string, 0, len(node.MultiSelectValue.Options)) + for _, opt := range node.MultiSelectValue.Options { + names = append(names, string(opt.Name)) + } + value = names case "IssueFieldTextValue": fieldIDStr = node.TextValue.Field.FullDatabaseIDStr() value = string(node.TextValue.Value) @@ -641,13 +747,16 @@ type listIssuesQueryWithLabelsAndSinceWithoutFieldValues struct { } // IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value -// field should be set per filter (the monolith resolver rejects multiple). +// field should be set per filter (the monolith resolver rejects multiple). For multi_select fields, +// MultiSelectOptionValues uses all-of (AND) semantics: an issue matches only if it has every listed +// option set on the field. To match any-of, send separate filter calls. type IssueFieldValueFilter struct { - FieldName githubv4.String `json:"fieldName"` - TextValue *githubv4.String `json:"textValue,omitempty"` - DateValue *githubv4.String `json:"dateValue,omitempty"` - NumberValue *githubv4.Float `json:"numberValue,omitempty"` - SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"` + FieldName githubv4.String `json:"fieldName"` + TextValue *githubv4.String `json:"textValue,omitempty"` + DateValue *githubv4.String `json:"dateValue,omitempty"` + NumberValue *githubv4.Float `json:"numberValue,omitempty"` + SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"` + MultiSelectOptionValues *[]githubv4.String `json:"multiSelectOptionValues,omitempty"` } // Implement the interface for all query types @@ -2369,7 +2478,8 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie } var q searchIssuesNodesQuery - if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if err := gqlClient.Query(ctxWithFeatures, &q, map[string]any{"ids": ids}); err != nil { return nil, err } @@ -2684,12 +2794,7 @@ func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) * return utils.NewToolResultAwaitingFormSubmission(msg) } -// IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write -// (with the issue_fields parameter). LegacyIssueWrite is served when the flag -// is off. Both register under the tool name "issue_write"; exactly one is -// active at a time via mutually exclusive feature-flag annotations. When the -// flag is removed, delete LegacyIssueWrite outright and drop the feature-flag -// fields on IssueWrite. +// IssueWrite creates the consolidated issue_write tool. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool[IssueWriteInput, *IssueWriteOutput]( ToolsetMetadataIssues, @@ -2793,7 +2898,7 @@ Options are: }, "issue_fields": { Type: "array", - Description: "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'.", + Description: "Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', 'field_option_names', or 'delete: true'.", Items: &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -2808,19 +2913,30 @@ Options are: Description: "Value to set. Use for text, number, and date fields " + "(date as YYYY-MM-DD). For single-select fields, prefer " + "'field_option_name' so the option is validated before the API " + - "call. Cannot be combined with 'field_option_name' or 'delete: true'.", + "call. Cannot be combined with 'field_option_name', " + + "'field_option_names', or 'delete: true'.", }, "field_option_name": { Type: "string", Description: "Option name for single-select fields. Validated against " + "the field's options before the API call. Cannot be combined with " + - "'value' or 'delete: true'.", + "'value', 'field_option_names', or 'delete: true'.", + }, + "field_option_names": { + Type: "array", + Items: &jsonschema.Schema{ + Type: "string", + }, + Description: "Option names for multi-select fields. All names are validated " + + "against the field's options before the API call. An empty array " + + "is rejected — use 'delete: true' to clear the field. " + + "Cannot be combined with 'value', 'field_option_name', or 'delete: true'.", }, "delete": { Type: "boolean", Description: "Set to true to clear this field's current value on the " + "issue. When false or omitted, this property is ignored. Cannot " + - "be true when 'value' or 'field_option_name' is provided.", + "be true when 'value', 'field_option_name', or 'field_option_names' is provided.", }, }, Required: []string{"field_name"}, @@ -3659,8 +3775,9 @@ type ListIssuesInput struct { } type IssueFieldFilterInput struct { - FieldName string `json:"field_name"` - Value string `json:"value"` + FieldName string `json:"field_name"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` } type ListIssuesOutput struct { @@ -3733,6 +3850,31 @@ func structuredListIssuesOutput(response MinimalIssuesResponse, fields []string) } func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + fieldFiltersDescription := "Filter by custom issue field values. Each entry takes a field_name and either 'value' (text, number, YYYY-MM-DD date, or single-select option name) or 'values' (multi-select option names). For multi-select fields, all listed values must be set on an issue for it to match (AND semantics) — to match any-of, make multiple list_issues calls and union the results." + valueDescription := "Value to filter on for text, number, date, or single-select fields. For single-select, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value. Cannot be combined with 'values'." + + fieldFilterItemProps := map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + }, + "value": { + Type: "string", + Description: valueDescription, + }, + "values": { + Type: "array", + Description: "Option names to filter on for multi-select fields. Matches issues that have ALL of these options set (AND semantics). To match any-of, make multiple list_issues calls. Cannot be combined with 'value'.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + } + + // Only field_name is required because either `value` or `values` is + // acceptable; the handler enforces exactly one at runtime. + fieldFilterRequired := []string{"field_name"} + schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -3772,20 +3914,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, "field_filters": { Type: "array", - Description: "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + Description: fieldFiltersDescription, Items: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "field_name": { - Type: "string", - Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", - }, - "value": { - Type: "string", - Description: "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", - }, - }, - Required: []string{"field_name", "value"}, + Type: "object", + Properties: fieldFilterItemProps, + Required: fieldFilterRequired, }, }, }, @@ -3876,10 +4009,19 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if filter.FieldName == "" { return utils.NewToolResultError("field_filters entry: missing required parameter: field_name"), output, nil } - if filter.Value == "" { - return utils.NewToolResultError(fmt.Sprintf("field_filters entry %q: missing required parameter: value", filter.FieldName)), output, nil + switch { + case filter.Value != "" && len(filter.Values) > 0: + return utils.NewToolResultError(fmt.Sprintf("field_filters entry %q: provide either 'value' or 'values', not both", filter.FieldName)), output, nil + case filter.Value == "" && len(filter.Values) == 0: + return utils.NewToolResultError(fmt.Sprintf("field_filters entry %q: missing 'value' (for text/number/date/single_select) or 'values' (for multi_select)", filter.FieldName)), output, nil } - rawFilters = append(rawFilters, rawFieldFilter{Name: filter.FieldName, Value: filter.Value}) + rawFilters = append(rawFilters, rawFieldFilter{ + Name: filter.FieldName, + Value: filter.Value, + Values: filter.Values, + HasValue: filter.Value != "", + HasValues: len(filter.Values) > 0, + }) } // Get pagination parameters and convert to GraphQL format @@ -4028,16 +4170,40 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return st } -// rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. +// rawFieldFilter is the user-supplied {field_name, value|values} entry before type resolution. +// Exactly one of Value or Values is set per entry. type rawFieldFilter struct { - Name string - Value string + Name string + Value string + Values []string + HasValue bool + HasValues bool +} + +// parseStringSlice coerces a JSON-decoded value into a []string, accepting either []string or []any. +func parseStringSlice(raw any) ([]string, error) { + switch v := raw.(type) { + case []string: + return v, nil + case []any: + out := make([]string, 0, len(v)) + for i, item := range v { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("index %d is not a string", i) + } + out = append(out, s) + } + return out, nil + default: + return nil, fmt.Errorf("expected array, got %T", raw) + } } // resolveFieldFilters matches each raw filter against a known field definition and // coerces the value into the right typed slot on IssueFieldValueFilter. Matching is // case-insensitive on field name; option names are also matched case-insensitively for -// single-select fields. +// single-select and multi-select fields. Multi-select filters use all-of (AND) semantics. func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]IssueFieldValueFilter, error) { byName := make(map[string]IssueField, len(fields)) knownNames := make([]string, 0, len(fields)) @@ -4056,34 +4222,50 @@ func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]Is filter := IssueFieldValueFilter{FieldName: githubv4.String(field.Name)} switch field.DataType { case "SINGLE_SELECT": - // Validate the option name against the field's options so we fail fast - // with a useful error instead of an opaque GraphQL one. - var matched string - for _, o := range field.Options { - if strings.EqualFold(o.Name, rf.Value) { - matched = o.Name - break - } + if !rf.HasValue { + return nil, fmt.Errorf("field_filters: field %q is single_select, use 'value' (single option name)", field.Name) } - if matched == "" { - optionNames := make([]string, 0, len(field.Options)) - for _, o := range field.Options { - optionNames = append(optionNames, o.Name) - } - return nil, fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", rf.Value, field.Name, strings.Join(optionNames, ", ")) + matched, err := matchOption(field, rf.Value) + if err != nil { + return nil, err } v := githubv4.String(matched) filter.SingleSelectOptionValue = &v + case "MULTI_SELECT": + if !rf.HasValues { + return nil, fmt.Errorf("field_filters: field %q is multi_select, use 'values' (array of option names)", field.Name) + } + if len(rf.Values) == 0 { + return nil, fmt.Errorf("field_filters: field %q is multi_select and requires at least one value", field.Name) + } + matched := make([]githubv4.String, 0, len(rf.Values)) + for _, raw := range rf.Values { + m, err := matchOption(field, raw) + if err != nil { + return nil, err + } + matched = append(matched, githubv4.String(m)) + } + filter.MultiSelectOptionValues = &matched case "TEXT": + if !rf.HasValue { + return nil, fmt.Errorf("field_filters: field %q is text, use 'value'", field.Name) + } v := githubv4.String(rf.Value) filter.TextValue = &v case "DATE": + if !rf.HasValue { + return nil, fmt.Errorf("field_filters: field %q is date, use 'value' (YYYY-MM-DD)", field.Name) + } if _, err := time.Parse("2006-01-02", rf.Value); err != nil { return nil, fmt.Errorf("field_filters: %q is not a valid date for %q (expected YYYY-MM-DD): %s", rf.Value, field.Name, err.Error()) } v := githubv4.String(rf.Value) filter.DateValue = &v case "NUMBER": + if !rf.HasValue { + return nil, fmt.Errorf("field_filters: field %q is number, use 'value'", field.Name) + } n, err := strconv.ParseFloat(rf.Value, 64) if err != nil { return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) @@ -4098,6 +4280,21 @@ func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]Is return out, nil } +// matchOption returns the canonical option name from field.Options matching value case-insensitively, +// or an error listing the valid options. +func matchOption(field IssueField, value string) (string, error) { + for _, o := range field.Options { + if strings.EqualFold(strings.TrimSpace(o.Name), strings.TrimSpace(value)) { + return o.Name, nil + } + } + optionNames := make([]string, 0, len(field.Options)) + for _, o := range field.Options { + optionNames = append(optionNames, o.Name) + } + return "", fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", value, field.Name, strings.Join(optionNames, ", ")) +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index eab4a204cd..26a1ff93c8 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -945,6 +945,7 @@ type IssueFieldCreateOrUpdateInput struct { NumberValue *githubv4.Float `json:"numberValue,omitempty"` DateValue *githubv4.String `json:"dateValue,omitempty"` SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"` + MultiSelectOptionIDs *[]githubv4.ID `json:"multiSelectOptionIds,omitempty"` Delete *githubv4.Boolean `json:"delete,omitempty"` Rationale *githubv4.String `json:"rationale,omitempty"` Confidence *string `json:"confidence,omitempty"` @@ -1003,7 +1004,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv }, "fields": { Type: "array", - Description: "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.", + Description: "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, 'single_select_option_id' (the GraphQL node ID of the option) for single select fields, or 'multi_select_option_ids' (an array of GraphQL node IDs) for multi select fields. Set 'delete' to true to remove a field value.", MinItems: new(1), Items: &jsonschema.Schema{ Type: "object", @@ -1028,6 +1029,13 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv Type: "string", Description: "The GraphQL node ID of the option to set for a single select field", }, + "multi_select_option_ids": { + Type: "array", + Description: "The GraphQL node IDs of the options to set for a multi select field", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, "delete": { Type: "boolean", Description: "Set to true to delete this field value", diff --git a/pkg/github/issues_multiselect_test.go b/pkg/github/issues_multiselect_test.go new file mode 100644 index 0000000000..2f92f2a63b --- /dev/null +++ b/pkg/github/issues_multiselect_test.go @@ -0,0 +1,200 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func multiSelectField() IssueField { + return IssueField{ + ID: "IFMS_1", + Name: "Components", + DataType: "MULTI_SELECT", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_AUTH", Name: "Auth"}, + {ID: "OPT_BILL", Name: "Billing"}, + {ID: "OPT_API", Name: "API"}, + }, + } +} + +func Test_resolveFieldFilters_MultiSelect(t *testing.T) { + t.Parallel() + + t.Run("matches canonical option names", func(t *testing.T) { + out, err := resolveFieldFilters([]rawFieldFilter{{ + Name: "Components", + Values: []string{" auth ", "BILLING"}, + HasValues: true, + }}, []IssueField{multiSelectField()}) + 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) + }) + + tests := []struct { + name string + fields []IssueField + filter rawFieldFilter + wantErr string + }{ + { + name: "rejects unknown option", + fields: []IssueField{multiSelectField()}, + filter: rawFieldFilter{Name: "Components", Values: []string{"Nope"}, HasValues: true}, + wantErr: "\"Nope\" is not a valid option", + }, + { + name: "rejects scalar value", + fields: []IssueField{multiSelectField()}, + filter: rawFieldFilter{Name: "Components", Value: "Auth", HasValue: true}, + wantErr: "is multi_select, use 'values'", + }, + { + name: "rejects values for single select", + fields: []IssueField{{ + Name: "Priority", + DataType: "SINGLE_SELECT", + Options: []IssueSingleSelectFieldOption{{Name: "P1"}}, + }}, + filter: rawFieldFilter{Name: "Priority", Values: []string{"P1"}, HasValues: true}, + wantErr: "is single_select, use 'value'", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveFieldFilters([]rawFieldFilter{tt.filter}, tt.fields) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_fragmentToMinimalFieldValue_MultiSelect(t *testing.T) { + t.Parallel() + + fv := IssueFieldValueFragment{TypeName: "IssueFieldMultiSelectValue"} + fv.MultiSelectValue.Field.MultiSelect.Name = "Components" + fv.MultiSelectValue.Options = []struct { + Name githubv4.String + }{{Name: "Auth"}, {Name: "Billing"}} + + value, ok := fragmentToMinimalFieldValue(fv) + require.True(t, ok) + assert.Equal(t, MinimalFieldValue{ + Field: "Components", + Values: []string{"Auth", "Billing"}, + }, value) +} + +func Test_optionalIssueWriteFields_MultiSelect(t *testing.T) { + t.Parallel() + + 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, []string{"Auth", "Billing"}, fields[0].FieldOptionNames) + + tests := []struct { + name string + item map[string]any + wantErr string + }{ + { + name: "rejects empty options", + item: map[string]any{"field_name": "Components", "field_option_names": []any{}}, + wantErr: "use 'delete: true'", + }, + { + name: "rejects multiple setters", + item: map[string]any{ + "field_name": "Components", + "value": "Auth", + "field_option_names": []any{"Auth"}, + }, + wantErr: "cannot specify more than one", + }, + { + name: "rejects delete with options", + item: map[string]any{ + "field_name": "Components", + "delete": true, + "field_option_names": []any{"Auth"}, + }, + wantErr: "cannot specify 'delete' together with", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := optionalIssueWriteFields(map[string]any{"issue_fields": []any{tt.item}}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func Test_resolveIssueRequestFieldValues_MultiSelect(t *testing.T) { + t.Parallel() + + newClient := func() *githubv4.Client { + response := 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{"name": "Auth"}, + map[string]any{"name": "Billing"}, + }, + }}, + }, + }, + }) + return githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + response, + ), + )) + } + + values, _, err := resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", []issueWriteFieldInput{{ + FieldName: "Components", + FieldOptionNames: []string{"auth", "BILLING"}, + }}) + require.NoError(t, err) + require.Len(t, values, 1) + assert.Equal(t, []string{"Auth", "Billing"}, values[0].Value) + + _, _, err = resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", []issueWriteFieldInput{{ + FieldName: "Components", + FieldOptionNames: []string{"Nope"}, + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Nope") + + _, _, err = resolveIssueRequestFieldValues(context.Background(), newClient(), "owner", "repo", []issueWriteFieldInput{{ + FieldName: "Components", + FieldOptionName: "Auth", + }}) + require.Error(t, err) + assert.Contains(t, err.Error(), "field_option_names") +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 79eeb22100..62738b4f55 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -52,20 +52,49 @@ func newRepoAccessHTTPClient() *http.Client { return &http.Client{Transport: &repoAccessMockTransport{responses: responses}} } -const issueReadEnrichmentQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}},parent{number,title,state,url,author{login},repository{nameWithOwner}},closedByPullRequestsReferences(first: 5, includeClosedPrs: true, orderByState: true){totalCount,nodes{number,title,state,url,author{login},repository{nameWithOwner}}},subIssuesSummary{total,completed,percentCompleted}}}}" - -const searchIssueFieldValuesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" - // newIssueReadEnrichmentMatcher builds a matcher for the issue_read `get` enrichment query for a // single issue node ID. func newIssueReadEnrichmentMatcher(nodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { + queryString := githubv4mock.NewQueryMatcher( + issueReadEnrichmentQuery{}, + map[string]any{"ids": []githubv4.ID{githubv4.ID(nodeID)}}, + githubv4mock.GQLResponse{}, + ).Request return githubv4mock.NewQueryMatcher( - issueReadEnrichmentQueryString, + queryString, map[string]any{"ids": []any{nodeID}}, response, ) } +func searchIssuesQueryString() string { + return githubv4mock.NewQueryMatcher( + searchIssuesNodesQuery{}, + map[string]any{"ids": []githubv4.ID{}}, + githubv4mock.GQLResponse{}, + ).Request +} + +func listIssuesQueryString(query any, hasLabels, hasSince bool) string { + vars := map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "states": []githubv4.IssueState{}, + "orderBy": githubv4.IssueOrderField("CREATED_AT"), + "direction": githubv4.OrderDirection("DESC"), + "first": githubv4.Int(30), + "after": (*githubv4.String)(nil), + "issueFieldValues": []IssueFieldValueFilter{}, + } + if hasLabels { + vars["labels"] = []githubv4.String{} + } + if hasSince { + vars["since"] = githubv4.DateTime{} + } + return githubv4mock.NewQueryMatcher(query, vars, githubv4mock.GQLResponse{}).Request +} + func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.Body == nil { return nil, fmt.Errorf("missing request body") @@ -1751,7 +1780,7 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { } else { gqlHTTPClient := tt.gqlHTTPClient if gqlHTTPClient == nil { - matcher := githubv4mock.NewQueryMatcher(searchIssueFieldValuesQueryString, gqlVars, tt.gqlResponse) + matcher := githubv4mock.NewQueryMatcher(searchIssuesQueryString(), gqlVars, tt.gqlResponse) gqlHTTPClient = githubv4mock.NewMockedHTTPClient(matcher) } deps = BaseDeps{ @@ -1829,11 +1858,14 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { } func Test_CreateIssue(t *testing.T) { - // Verify tool definition once + // Verify tool definition once. issue_write is a single consolidated tool + // (multi-select unconditional) that owns the canonical issue_write.snap and + // is hidden when the granular issue tools are enabled. serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) require.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagIssuesGranular)}, serverTool.FeatureRule.Features()) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties["issue_fields"].Items.Properties, "field_option_names", "issue_fields must advertise the multi-select field_option_names slot") assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) @@ -2023,7 +2055,7 @@ func Test_CreateIssue(t *testing.T) { }, }, expectError: false, - expectedErrMsg: "cannot specify both value and field_option_name", + expectedErrMsg: "cannot specify more than one of value, field_option_name, or field_option_names", }, { name: "issue_fields rejects delete true with value", @@ -3217,6 +3249,9 @@ func Test_ListIssues(t *testing.T) { tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // Multi-select is unconditional, so field_filters always advertises `values`. + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties["field_filters"].Items.Properties, "values", "field_filters must advertise the multi-select `values` slot") + assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") @@ -3506,9 +3541,8 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qBasicNoLabels := listIssuesQueryString(ListIssuesQuery{}, false, false) + qWithLabels := listIssuesQueryString(ListIssuesQueryTypeWithLabels{}, true, false) for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -4055,6 +4089,17 @@ func Test_ListIssues_FieldFilters(t *testing.T) { map[string]any{"id": "OPT_P2", "name": "P2", "color": "yellow"}, }, }, + map[string]any{ + "__typename": "IssueFieldMultiSelect", + "id": "IFMS_1", + "name": "Components", + "dataType": "MULTI_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{"id": "OPT_AUTH", "name": "Auth", "color": "red"}, + map[string]any{"id": "OPT_BILL", "name": "Billing", "color": "yellow"}, + }, + }, map[string]any{ "__typename": "IssueFieldText", "id": "IFT_1", @@ -4091,8 +4136,8 @@ func Test_ListIssues_FieldFilters(t *testing.T) { ) } - qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qNoLabels := listIssuesQueryString(ListIssuesQuery{}, false, false) + qWithLabels := listIssuesQueryString(ListIssuesQueryTypeWithLabels{}, true, false) baseVars := func() map[string]any { return map[string]any{ @@ -4128,6 +4173,44 @@ func Test_ListIssues_FieldFilters(t *testing.T) { require.False(t, res.IsError, getTextResult(t, res).Text) }) + t.Run("multi select field filter", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Components", "multiSelectOptionValues": []any{"Auth", "Billing"}}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Components", "values": []any{"auth", "BILLING"}}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("field filter rejects value and values together", func(t *testing.T) { + deps := BaseDeps{} + handler := serverTool.Handler(deps) + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Components", "value": "Auth", "values": []any{"Billing"}}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + assert.Contains(t, getTextResult(t, res).Text, "provide either 'value' or 'values', not both") + }) + t.Run("text field filter combined with labels", func(t *testing.T) { vars := baseVars() vars["labels"] = []any{"bug"} @@ -4319,11 +4402,8 @@ func Test_ListIssues_FieldFilters(t *testing.T) { assert.Contains(t, getTextResult(t, res).Text, "not a valid date") }) - // Query string fragments for the `since` variants. Built by string concatenation - // because they only differ from the base variants by the variable declaration and - // the filterBy clause. - qNoLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qNoLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):] - qLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qWithLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):] + qNoLabelsWithSince := listIssuesQueryString(ListIssuesQueryWithSince{}, false, true) + qLabelsWithSince := listIssuesQueryString(ListIssuesQueryTypeWithLabelsWithSince{}, true, true) t.Run("field filter with since", func(t *testing.T) { vars := baseVars() @@ -4453,7 +4533,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } - query := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + query := listIssuesQueryString(ListIssuesQuery{}, false, false) vars := map[string]any{ "owner": "octocat", @@ -4604,7 +4684,7 @@ func TestIssueWriteUpdatesIssueType(t *testing.T) { } func Test_UpdateIssue(t *testing.T) { - // Verify tool definition + // Verify tool definition. issue_write owns the canonical issue_write.snap. serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) @@ -4780,7 +4860,7 @@ func Test_UpdateIssue(t *testing.T) { mockedGQLClient: githubv4mock.NewMockedHTTPClient( // fetch-and-merge: returns no existing fields so the incoming values are used as-is githubv4mock.NewQueryMatcher( - "query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){issue(number: $number){issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}", + "query($number:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){issue(number: $number){issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldMultiSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},options{name}},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldMultiSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}", map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index d355eb1a2e..a1c5048738 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -967,6 +967,15 @@ func fragmentToMinimalFieldValue(fv IssueFieldValueFragment) (MinimalFieldValue, Field: fv.SingleSelectValue.Field.Name(), Value: string(fv.SingleSelectValue.Value), }, true + case "IssueFieldMultiSelectValue": + values := make([]string, 0, len(fv.MultiSelectValue.Options)) + for _, opt := range fv.MultiSelectValue.Options { + values = append(values, string(opt.Name)) + } + return MinimalFieldValue{ + Field: fv.MultiSelectValue.Field.Name(), + Values: values, + }, true case "IssueFieldTextValue": return MinimalFieldValue{ Field: fv.TextValue.Field.Name(), diff --git a/pkg/github/typed_compatibility_test.go b/pkg/github/typed_compatibility_test.go index d1e68a6b1f..a556f407d0 100644 --- a/pkg/github/typed_compatibility_test.go +++ b/pkg/github/typed_compatibility_test.go @@ -177,7 +177,7 @@ func TestTypedCompatibilitySearchFieldValuePresence(t *testing.T) { map[string]any{"id": "I_1", "issueFieldValues": map[string]any{"nodes": []any{}}}, }}) } - matcher := githubv4mock.NewQueryMatcher(searchIssueFieldValuesQueryString, map[string]any{"ids": []any{"I_1"}}, response) + matcher := githubv4mock.NewQueryMatcher(searchIssuesQueryString(), map[string]any{"ids": []any{"I_1"}}, response) deps := BaseDeps{ Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetSearchIssues: mockResponse(t, http.StatusOK, &github.IssuesSearchResult{Total: new(1), IncompleteResults: new(false), Issues: []*github.Issue{issue}}),