Skip to content

Add multi_select issue field support#2659

Open
owenniblock wants to merge 4 commits into
mainfrom
owenniblock/multi-select-issue-fields
Open

Add multi_select issue field support#2659
owenniblock wants to merge 4 commits into
mainfrom
owenniblock/multi-select-issue-fields

Conversation

@owenniblock

@owenniblock owenniblock commented Jun 10, 2026

Copy link
Copy Markdown
Member

Summary

Threads the new multi_select issue field type through list_issue_fields, issue_write, and list_issues (read + filter), mirroring how single_select is wired today.

Why

Phase C of github/issues#21956. Multi-select issue field support has shipped on the GraphQL/REST side in github/github#433220; GraphQL filter inputs are landing in github/github#435047 (by @akenneth). Until this PR the MCP server silently dropped multi-select fields everywhere.

Refs github/issues#21956 (multi-phase, don't auto-close).

What changed

  • list_issue_fields — added IssueFieldMultiSelect inline fragment + typename case so multi-select field definitions are surfaced.
  • issue_write — new field_option_names: []string slot on issue_fields[] items. Server resolves the names against the field's options (case-insensitive, returns canonical names) and sends []string to REST, matching IssueField#build_value_attributes. Mutual-exclusion validation extended to "exactly one of value / field_option_name / field_option_names / delete: true". Multi-select rejects raw value with a hint to use field_option_names.
  • list_issues
    • GraphQL value fragment understands IssueFieldMultiSelectValue (plain list of options { name }, not a connection — confirmed against app/graphql/objects/issue_field_multi_select_value.rb); MinimalFieldValue.Values is populated.
    • field_filters[] accepts a new values: []string slot mapped to MultiSelectOptionValues *[]githubv4.String, mirroring the input shape from github/github#435047.
    • All-of (AND) semantics -> documented in both the tool description and the item description: all listed values must be set on an issue for it to match. To match any-of, callers make multiple list_issues calls and union the results.
  • Latent bug fixfetchIssueFieldValuesByNodeID was missing the ghcontext.WithGraphQLFeatures("issue_fields", "repo_issue_fields") wrap, so without it the new fragment would have silently no-op'd on dotcom even with the right feature flags enabled.

Out of scope / known follow-ups

  • REST multi_select_options typed slot on MinimalIssueFieldValue — deliberately skipped. issue_read clears REST field values and always re-fetches via GraphQL (fetchIssueFieldValuesByNodeID), so a typed REST slot would always be empty. The GraphQL fragment change in this PR covers the read path end-to-end. (go-github v87 also doesn't yet decode the REST field; worth picking up when it does.)
  • set_issue_fields granular tool in pkg/github/issues_granular.go — not touched in this PR. GraphQL IssueFieldCreateOrUpdateInput already has multi_select_option_ids per github/github#433220, so wiring it through is a natural follow-up.
  • Empty field_option_names: []IssueField#build_value_attributes rejects this with 422 (must contain at least one option) per github/github#433220. This PR matches that. If callers ever want "empty = clear the field" semantics (consistent with ProjectV2 / assignees / labels), that's a dotcom-side decision — use delete: true to clear today.

MCP impact

  • Tool schema or behavior changed -> issue_write adds field_option_names; list_issues field_filters adds values; list_issue_fields returns multi-select fields it previously dropped. Toolsnaps + generated docs updated.

Prompts tested (tool changes only)

Validated via unit tests + existing GraphQL-string integration tests. End-to-end against dotcom needs a review-lab — see @kelsey-myers's testing guide. Example prompts the new code paths cover:

  • "Set the Components field on issue [docker] build arm64 #42 to Auth and Billing." -> issue_write with field_option_names: ["Auth", "Billing"].
  • "List issues where Components has both Auth and Billing set." -> list_issues with field_filters: [{field_name: "Components", values: ["Auth", "Billing"]}]. Note: filter end-to-end behaviour depends on github/github#435047 landing — the Go shape is final per that PR, but dotcom won't honour the input until it merges.

Security / limits

  • No security or limits impact -> reuses the existing issue_fields / repo_issue_fields GraphQL feature flags and the existing field-resolution / option-validation paths.

Tool renaming

  • I am not renaming tools as part of this PR

Lint & tests

  • Linted locally with ./script/lint
  • Tested locally with ./script/test

New: pkg/github/issues_multiselect_test.go (5 functions covering parseRawFieldFilters, resolveFieldFilters, fragmentToMinimalFieldValue, IssueFieldRef.Name/FullDatabaseIDStr, optionalIssueWriteFields, resolveIssueRequestFieldValues via githubv4mock). Existing hardcoded GraphQL-string expectations in issues_test.go patched to include the new inline fragments.

Docs

  • Updated (README / docs / examples) -> script/generate-docs regenerated README.md, docs/remote-server.md, docs/insiders-features.md, docs/feature-flags.md, docs/tool-renaming.md, plus 3 toolsnaps.

cc @github/github-mcp-server

owenniblock and others added 2 commits June 10, 2026 11:20
Threads the multi_select issue field type through list_issue_fields,
issue_write, and list_issues (read + filter):

- C0: list_issue_fields surfaces IssueFieldMultiSelect definitions.
- C1: issue_write accepts a new `field_option_names` slot for
  multi_select fields, validates options against the field definition,
  and sends an array of option names to the REST API (matching
  IssueField#build_value_attributes from github/github#433220).
- C2: list_issues + GraphQL value fragment understand
  IssueFieldMultiSelectValue, and field_filters accepts a `values`
  array that maps to the new `multiSelectOptionValues` GraphQL input
  added in github/github#435047. Filter uses all-of (AND) semantics;
  documented in the tool description.

Wraps fetchIssueFieldValuesByNodeID in the issue_fields/
repo_issue_fields GraphQL feature context so the new fragment is
honoured by the API.

Refs github/issues#21956.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tion

The REST update endpoint uses set semantics for issue_field_values: sending
{"issue_field_values": [...]} overwrites the entire list. We were relying on
that to clear deleted fields by omitting them from the merged payload, but
Go's omitempty strips an empty []*IssueRequestFieldValue, so when the only
remaining field was the one being deleted, nothing got sent and the field
kept its old value.

Capture each field's GraphQL node ID alongside its database ID at resolve
time, and after the REST PATCH succeeds run deleteIssueFieldValue per
deletion. This is idempotent and works even when the kept list is empty.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
owenniblock and others added 2 commits June 12, 2026 10:02
The previous wording said 'Setting an empty array clears the field — use
delete: true for that instead', which was contradictory. An empty array
is actually rejected; clearing requires delete: true.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…field, add combined test

Three small fixes from code review:

1. optionalIssueWriteFields now returns a targeted error when
   field_option_names is present but empty, pointing the caller at
   delete:true. The generic 'must specify one of' message was
   confusing because the caller did specify field_option_names.

2. Drop the dead MultiSelectOptionIDs field from IssueFieldValueFilter.
   resolveFieldFilters only ever sets MultiSelectOptionValues; the IDs
   variant was declared but never wired.

3. Add Test_UpdateIssue_DeleteAndSetFieldsInSameCall covering a single
   UpdateIssue call that deletes one multi_select field and sets another
   field in the same request. Asserts the REST PATCH carries only the
   kept set (no null clearing) and that the deleteIssueFieldValue
   mutation fires for the deleted field with the right node IDs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@owenniblock owenniblock marked this pull request as ready for review June 12, 2026 12:44
@owenniblock owenniblock requested a review from a team as a code owner June 12, 2026 12:44
Copilot AI review requested due to automatic review settings June 12, 2026 12:44

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds end-to-end support for multi_select issue fields across the MCP server’s issue-field surfaces (list field definitions, set/clear values via issue_write, and read/filter via list_issues), aligning behavior and schema with the existing single-select plumbing and ensuring GraphQL feature-gated fragments are actually exercised.

Changes:

  • Threaded multi_select through GraphQL fragments + minimal output mapping (including MinimalFieldValue.Values).
  • Extended issue_write to accept field_option_names: []string for multi-select, with option validation + improved delete semantics.
  • Extended list_issues field filtering to accept values: []string for multi-select (AND semantics) and updated toolsnaps/docs/tests accordingly.
Show a summary per file
File Description
pkg/github/minimal_types.go Adds multi-select support in GraphQL→minimal field-value conversion (populates Values).
pkg/github/issues.go Adds multi-select fragments, issue_write parsing/validation, list_issues multi-select filtering, and GraphQL-backed delete-on-clear behavior.
pkg/github/issues_test.go Updates hardcoded GraphQL query string expectations and error-message assertions for new fragments/validation.
pkg/github/issues_multiselect_test.go New targeted tests for multi-select parsing, filter resolution, fragment conversion, and delete mutation behavior.
pkg/github/issues_granular.go Introduces DeleteIssueFieldValueInput type used by the new delete mutation path.
pkg/github/issue_fields.go Extends list_issue_fields to surface multi_select field definitions and options.
pkg/github/toolsnaps/list_issues_ff_remote_mcp_issue_fields.snap Updates schema snapshot for new field_filters[].values and revised description/required fields.
pkg/github/toolsnaps/list_issue_fields.snap Updates schema snapshot to include multi_select in description/output expectations.
pkg/github/toolsnaps/issue_write_ff_remote_mcp_issue_fields.snap Updates schema snapshot to include field_option_names and updated mutual-exclusion wording.
docs/insiders-features.md Regenerates tool docs reflecting new issue_write.issue_fields[].field_option_names and list_issues.field_filters[].values.
docs/feature-flags.md Regenerates feature-flag inventory/docs to reflect the updated tool schemas/descriptions.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 3

Comment thread pkg/github/issues.go
Comment on lines +2590 to +2614
// Clear any fields marked with delete:true via the GraphQL deleteIssueFieldValue
// mutation. The REST PATCH above can't do this reliably — Go's omitempty drops an
// empty issue_field_values array, leaving the old values intact.
if len(fieldsToDelete) > 0 {
issueID, _, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, 0)
if err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to look up issue for field deletion", err), nil
}
for _, deletion := range fieldsToDelete {
var mutation struct {
DeleteIssueFieldValue struct {
Issue struct {
Number githubv4.Int
}
} `graphql:"deleteIssueFieldValue(input: $input)"`
}
input := DeleteIssueFieldValueInput{
IssueID: issueID,
FieldID: deletion.NodeID,
}
if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil {
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to delete issue field value", err), nil
}
}
}
Comment thread pkg/github/issues.go
Comment on lines +297 to +315
var fieldOptionNames []string
_, hasNamesKey := itemMap["field_option_names"]
if rawNames := itemMap["field_option_names"]; hasNamesKey && rawNames != nil {
switch v := rawNames.(type) {
case []string:
fieldOptionNames = v
case []any:
fieldOptionNames = make([]string, 0, len(v))
for _, item := range v {
s, ok := item.(string)
if !ok {
return nil, fmt.Errorf("field_option_names for field %q must be an array of strings", fieldName)
}
fieldOptionNames = append(fieldOptionNames, s)
}
default:
return nil, fmt.Errorf("field_option_names for field %q must be an array of strings", fieldName)
}
}
Comment thread pkg/github/issues.go
Comment on lines +3352 to +3363
func matchOption(field IssueField, value string) (string, error) {
for _, o := range field.Options {
if strings.EqualFold(o.Name, 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, ", "))
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants