diff --git a/README.md b/README.md index 10d987a262..83fa7791fc 100644 --- a/README.md +++ b/README.md @@ -907,7 +907,7 @@ The following sets of tools are available: - `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. Only used 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. @@ -939,7 +939,7 @@ The following sets of tools are available: - **Required OAuth 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) - `labels`: Filter by labels (string[], optional) - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) - `owner`: Repository owner (string, required) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index c83e0c74be..0207d0603c 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -57,7 +57,7 @@ runtime behavior (such as output formatting) won't appear here. - `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. Only used 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. @@ -143,7 +143,7 @@ runtime behavior (such as output formatting) won't appear here. - **set_issue_fields** - Set Issue Fields - **Required OAuth 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) @@ -366,7 +366,7 @@ runtime behavior (such as output formatting) won't appear here. - **Required OAuth 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/insiders-features.md b/docs/insiders-features.md index 10df187a91..24ddc88b12 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -51,7 +51,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - `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. Only used 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. diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 55fd2dbcc2..0d11ee2254 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. Cannot be combined with 'value' or 'field_option_name'.", + "description": "Set to true to clear this field's current value on the issue. Cannot be combined with 'value', 'field_option_name', or 'field_option_names'.", "enum": [ true ], @@ -48,11 +48,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'.", + "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'.", "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'.", + "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'.", + "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'.", "type": [ "string", "number", diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index df9ae47ba6..c93c1a6960 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 5c68c01497..df84621177 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__/list_issues_ff_fields_param.snap b/pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap index 1055fe9947..751f3dbc5f 100644 --- a/pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap +++ b/pkg/github/__toolsnaps__/list_issues_ff_fields_param.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 c5b89077ac..8822e194b4 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -10,7 +10,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": { @@ -38,6 +38,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/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index f421fe1ebb..262670772a 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -346,7 +346,7 @@ func Test_LegacyListIssues_Definition(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}},comments{totalCount}," + listIssuesFieldsFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 58fd904e88..764eb8c5e0 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -1784,6 +1784,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` @@ -1833,6 +1838,134 @@ 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( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + 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", + "number": 5, + "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("multi select option ids must be strings", 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", "multi_select_option_ids": []any{"OPT_1", 42}}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "multi_select_option_ids must be an array of strings") + }) + t.Run("missing required parameter fields", func(t *testing.T) { deps := BaseDeps{} serverTool := GranularSetIssueFields(translations.NullTranslationHelper) @@ -1959,6 +2092,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` @@ -2073,6 +2211,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` @@ -2187,6 +2330,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` @@ -2278,6 +2426,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` @@ -2370,6 +2523,11 @@ func TestGranularSetIssueFields(t *testing.T) { SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index e06bdbfcf9..7cda9af56b 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -83,6 +83,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. @@ -104,12 +119,24 @@ type issueFieldsOrgQuery struct { } // ListIssueFields creates a tool to list issue field definitions for a repository or organization. +// +// Note: the underlying GraphQL fragment unconditionally includes the +// multi_select arm, so the OUTPUT will surface multi-select fields if the org +// has them (matching what the dotcom UI shows). func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + 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.", + ) + return buildListIssueFields(t, description) +} + +func buildListIssueFields(t translations.TranslationHelperFunc, description string) inventory.ServerTool { + return NewTool( 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: description, Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), ReadOnlyHint: true, @@ -167,7 +194,6 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool } return result, nil, nil }) - return st } // fetchIssueFields returns the issue field definitions for the given owner. @@ -224,6 +250,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 2c2b26ee2a..97f5e4a168 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -15,10 +15,13 @@ import ( ) func Test_ListIssueFields(t *testing.T) { - // Verify tool definition + // list_issue_fields is a single ungated tool that owns the canonical + // list_issue_fields.snap. Multi-select is unconditional. serverTool := ListIssueFields(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) + assert.Empty(t, serverTool.FeatureFlagEnable, "ListIssueFields must not require any flag to be enabled") + 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) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index bbccdc35d8..b528ff0359 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -41,10 +41,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 ( @@ -145,6 +146,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 { @@ -156,8 +166,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 @@ -171,6 +181,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"` @@ -186,6 +200,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) } @@ -202,6 +218,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) } @@ -209,8 +227,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 { @@ -225,12 +244,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 { @@ -265,6 +291,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, _ := OptionalParam[bool](itemMap, "delete") value, hasValue := itemMap["value"] if hasValue && value == nil { @@ -272,8 +308,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', 'field_option_name', or 'field_option_names'", fieldName) } issueFields = append(issueFields, issueWriteFieldInput{ FieldName: fieldName, @@ -282,24 +318,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 one of value, 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 @@ -328,6 +381,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 } @@ -356,6 +411,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) @@ -369,9 +427,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 @@ -387,6 +446,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{ @@ -398,6 +484,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) { @@ -438,6 +537,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) @@ -557,13 +663,16 @@ type ListIssuesQueryTypeWithLabelsWithSince 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 @@ -1854,7 +1963,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 } @@ -2117,12 +2227,9 @@ 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 is the consolidated issue_write tool. Its schema advertises the +// `field_option_names` slot on `issue_fields[]` items and its resolver accepts +// multi-select fields. It is disabled when the granular issue tools are enabled. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataIssues, @@ -2209,7 +2316,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{}}, @@ -2224,19 +2331,31 @@ 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'.", + "call. Cannot be combined with 'field_option_name', " + + "'field_option_names', or 'delete'.", }, "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'.", + "'value', 'field_option_names', or 'delete'.", + }, + "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'.", }, "delete": { Type: "boolean", Enum: []any{true}, Description: "Set to true to clear this field's current value on the " + - "issue. Cannot be combined with 'value' or 'field_option_name'.", + "issue. Cannot be combined with 'value', 'field_option_name', " + + "or 'field_option_names'.", }, }, Required: []string{"field_name"}, @@ -2669,27 +2788,53 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 // active for any given request thanks to mutually exclusive FeatureFlagEnable / // FeatureFlagDisable annotations. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { - st := listIssuesTool(t, true) + st := buildListIssues(t, true) st.FeatureFlagEnable = FeatureFlagFieldsParam return st } // LegacyListIssues is the FeatureFlagFieldsParam-disabled variant of list_issues. -// It exposes the original schema (no `fields` parameter) and never filters +// It exposes the schema without the `fields` parameter and never filters // results, so it acts as the kill switch when the flag is off. It owns the // canonical list_issues.snap; the flag-enabled variant owns // list_issues_ff_.snap. Delete this function when the flag is removed. func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { - st := listIssuesTool(t, false) + st := buildListIssues(t, false) st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} return st } -// listIssuesTool builds the list_issues tool. When includeFields is true the -// tool advertises the optional `fields` parameter, filters each issue to the -// requested subset, and emits fields telemetry. When false it is the original -// tool with no fields parameter and no filtering. -func listIssuesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { +// buildListIssues builds the list_issues tool. The `field_filters` schema always +// advertises the `values` slot for multi-select option filters. When +// includeFields is true the tool also advertises the optional `fields` +// parameter, filters each issue to the requested subset, and emits fields +// telemetry; when false it has no `fields` parameter and does no filtering. +func buildListIssues(t translations.TranslationHelperFunc, includeFields bool) 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 parser enforces exactly-one-of at runtime. + fieldFilterRequired := []string{"field_name"} + schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -2729,20 +2874,15 @@ func listIssuesTool(t translations.TranslationHelperFunc, includeFields bool) in }, "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, + // Legacy keeps the historical strict shape (field_name + value). + // MS-aware loosens to just field_name because either `value` or + // `values` is acceptable — the parser enforces "exactly one of" + // at runtime. + Required: fieldFilterRequired, }, }, }, @@ -2986,15 +3126,21 @@ func listIssuesTool(t translations.TranslationHelperFunc, includeFields bool) in 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 } // parseRawFieldFilters extracts the optional field_filters parameter into a list of -// {name, value} pairs. The value is always a string here; type-aware coercion happens -// later in resolveFieldFilters once we know each field's data_type. +// {name, value|values} entries. The scalar value is always a string here; type-aware +// coercion happens later in resolveFieldFilters once we know each field's data_type. +// Each entry must supply exactly one of `value` (scalar — text/number/date/single_select) +// or `values` (array — multi_select). func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { raw, ok := args["field_filters"] if !ok { @@ -3023,19 +3169,63 @@ func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { if err != nil { return nil, fmt.Errorf("field_filters entry: %s", err.Error()) } - value, err := RequiredParam[string](entry, "value") - if err != nil { - return nil, fmt.Errorf("field_filters entry %q: %s", fieldName, err.Error()) + + filter := rawFieldFilter{Name: fieldName} + + if rawValue, present := entry["value"]; present { + value, ok := rawValue.(string) + if !ok { + return nil, fmt.Errorf("field_filters entry %q: value must be a string", fieldName) + } + filter.Value = value + filter.HasValue = true + } + + if rawValues, present := entry["values"]; present { + values, err := parseStringSlice(rawValues) + if err != nil { + return nil, fmt.Errorf("field_filters entry %q: values must be an array of strings: %s", fieldName, err.Error()) + } + filter.Values = values + filter.HasValues = true } - filters = append(filters, rawFieldFilter{Name: fieldName, Value: value}) + + switch { + case filter.HasValue && filter.HasValues: + return nil, fmt.Errorf("field_filters entry %q: provide either 'value' or 'values', not both", fieldName) + case !filter.HasValue && !filter.HasValues: + return nil, fmt.Errorf("field_filters entry %q: missing 'value' (for text/number/date/single_select) or 'values' (for multi_select)", fieldName) + } + + filters = append(filters, filter) } return filters, nil } +// 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)) @@ -3054,34 +3244,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()) @@ -3096,6 +3302,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 c1eb556c9c..2f94dd892e 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -1257,6 +1257,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"` @@ -1294,7 +1295,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: jsonschema.Ptr(1), Items: &jsonschema.Schema{ Type: "object", @@ -1319,6 +1320,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", @@ -1421,6 +1429,20 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv input.SingleSelectOptionID = &optionID valueCount++ } + if rawIDs, exists := fieldMap["multi_select_option_ids"]; exists && rawIDs != nil { + ids, err := parseStringSlice(rawIDs) + if err != nil { + return utils.NewToolResultError("multi_select_option_ids must be an array of strings"), nil, nil + } + if len(ids) > 0 { + optionIDs := make([]githubv4.ID, 0, len(ids)) + for _, s := range ids { + optionIDs = append(optionIDs, githubv4.ID(s)) + } + input.MultiSelectOptionIDs = &optionIDs + valueCount++ + } + } if _, exists := fieldMap["delete"]; exists { del, err := OptionalParam[bool](fieldMap, "delete") if err == nil && del { @@ -1431,10 +1453,10 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv } if valueCount == 0 { - return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true"), nil, nil + return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id, multi_select_option_ids) or delete: true"), nil, nil } if valueCount > 1 { - return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil + return utils.NewToolResultError("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"), nil, nil } if _, exists := fieldMap["rationale"]; exists { @@ -1501,6 +1523,11 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv SingleSelectValue struct { Name string } `graphql:"... on IssueFieldSingleSelectValue"` + MultiSelectValue struct { + Options []struct { + Name string + } + } `graphql:"... on IssueFieldMultiSelectValue"` DateValue struct { Value string } `graphql:"... on IssueFieldDateValue"` diff --git a/pkg/github/issues_multiselect_test.go b/pkg/github/issues_multiselect_test.go new file mode 100644 index 0000000000..892969db56 --- /dev/null +++ b/pkg/github/issues_multiselect_test.go @@ -0,0 +1,413 @@ +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") + }) +} diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c3ea692464..5deafbe13a 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -49,7 +49,7 @@ 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}},subIssuesSummary{total,completed,percentCompleted}}}}" +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 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}}},parent{number,title,state,url,author{login},repository{nameWithOwner}},subIssuesSummary{total,completed,percentCompleted}}}}" // newIssueReadEnrichmentMatcher builds a matcher for the issue_read `get` enrichment query for a // single issue node ID. @@ -1387,7 +1387,7 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { }, }) - const nodesQueryString = "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}}}}}}" + const nodesQueryString = "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 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}}}}}}" matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) @@ -1421,11 +1421,15 @@ 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.Empty(t, serverTool.FeatureFlagEnable) + assert.Empty(t, serverTool.FeatureFlagEnable, "IssueWrite must not require any flag to be enabled") + assert.ElementsMatch(t, []string{FeatureFlagIssuesGranular}, serverTool.FeatureFlagDisable, "IssueWrite must be hidden when the granular tools flag is on") + 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) @@ -1617,7 +1621,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", }, } @@ -1894,15 +1898,17 @@ func Test_issueWriteSchemaClassification(t *testing.T) { } func Test_ListIssues(t *testing.T) { - // Verify tool definition - serverTool := ListIssues(translations.NullTranslationHelper) - tool := serverTool.Tool // ListIssues is the FeatureFlagFieldsParam-enabled variant; it owns the // _ff_ snapshot. The canonical list_issues.snap is owned by // LegacyListIssues (see Test_LegacyListIssues_Definition). + serverTool := ListIssues(translations.NullTranslationHelper) + tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) + // 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") @@ -2186,7 +2192,7 @@ 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}}}" + issueFieldValuesSelection := "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}}}" 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}},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}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" @@ -2371,8 +2377,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}},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}},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 := "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}},comments{totalCount},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}}}},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}},comments{totalCount},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}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" baseVars := func() map[string]any { return map[string]any{ @@ -2733,7 +2739,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}},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 := "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}},comments{totalCount},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}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" vars := map[string]any{ "owner": "octocat", @@ -2820,7 +2826,7 @@ func Test_ListIssues_IFC_InsidersMode(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)) @@ -2995,7 +3001,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 75bc8f48f1..94fb1ef785 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -768,6 +768,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(),