Add multi_select issue field support (gated behind remote_mcp_issue_fields_multiselect)#2659
Add multi_select issue field support (gated behind remote_mcp_issue_fields_multiselect)#2659owenniblock wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
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_selectthrough GraphQL fragments + minimal output mapping (includingMinimalFieldValue.Values). - Extended
issue_writeto acceptfield_option_names: []stringfor multi-select, with option validation + improved delete semantics. - Extended
list_issuesfield filtering to acceptvalues: []stringfor 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
| 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, ", ")) | ||
| } |
There was a problem hiding this comment.
Done — trimming both sides now, with a regression test.
akenneth
left a comment
There was a problem hiding this comment.
nice work! left a few questions I am unsure about.
Also tagging @iulia-b and @kelsey-myers for extra 👀 since you are familiar with this part
| type issueFieldWriteMetadataNode struct { | ||
| TypeName githubv4.String `graphql:"__typename"` | ||
| IssueFieldText struct { | ||
| ID githubv4.ID |
There was a problem hiding this comment.
Why is this needed? this wasn't here before for other types right?
There was a problem hiding this comment.
Yeah, it's new. The delete: true path now calls the deleteIssueFieldValue GraphQL mutation, which takes the field's node id as input — and delete can target any field type, so we grab id on every fragment. Before this PR there was no GraphQL delete, so we never needed the node id.
| } | ||
|
|
||
| func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []issueWriteFieldInput) ([]*github.IssueRequestFieldValue, []int64, error) { | ||
| // fieldDeletion carries both identifiers needed to clear an issue field value: |
There was a problem hiding this comment.
Are you sure this change is needed?
There was a problem hiding this comment.
I think so — we added field_option_names as a third value setter, so the old 2-way value vs field_option_name mutual-exclusion check had to become 3-way. Happy to simplify if you can see a cleaner way though.
| // REST IssueField#build_value_attributes for multi_select expects an array of option names. | ||
| resolvedValue = resolvedNames | ||
| default: | ||
| // Raw value path. Reject it for multi_select so callers get a clear error |
There was a problem hiding this comment.
nit: i think we can reduce the comments here, the if is clear enough
| value = string(node.SingleSelectValue.Value) | ||
| case "IssueFieldMultiSelectValue": | ||
| fieldIDStr = node.MultiSelectValue.Field.FullDatabaseIDStr() | ||
| // REST IssueField#build_value_attributes for multi_select expects an array |
|
|
||
| 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") |
There was a problem hiding this comment.
Latent bug fix — fetchIssueFieldValuesByNodeID 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.
Interesting! What was broken here? is it searching with fields?
There was a problem hiding this comment.
Not search/filtering — it's the read enrichment. fetchIssueFieldValuesByNodeID backs the field_values you get on get_issue and search_issues output. It was missing the WithGraphQLFeatures("issue_fields", "repo_issue_fields") wrap, so on dotcom the new IssueFieldValues fragment would just come back empty even with the flags on — field values silently wouldn't show up. Adding the wrap makes it actually resolve.
| // 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 { |
There was a problem hiding this comment.
i am confused, wasn't this code already in place?
what were we doing with the previous fieldIDsToDelete ?
There was a problem hiding this comment.
Good question — it looked in place but it didn't actually work. Previously resolveIssueRequestFieldValues returned []int64 fieldIDsToDelete and we leaned on the REST PATCH to clear them by sending an empty value — but Go's omitempty drops the empty issue_field_values array, so the clear silently no-op'd and the old values stuck around. This PR swaps that for an explicit deleteIssueFieldValue GraphQL mutation per field, so fieldDeletion now carries both the db id (to keep it out of the REST payload) and the node id (for the mutation). So delete: true actually clears now.
786a2d1 to
c726b68
Compare
The dotcom REST update endpoint uses set semantics for issue_field_values:
sending {"issue_field_values": [...]} overwrites the whole list, and
anything not in the new list is treated as a deletion. UpdateIssue
already exploits this via merge-and-filter — delete:true on a field
removes it from the kept list and the server clears it as a side effect.
That breaks for one specific case: when the kept list ends up empty
(e.g. you're deleting the only remaining field value, or every field in
one call), go-github's omitempty tag on
`IssueRequest.IssueFieldValues` strips the empty slice from the JSON
body. The dotcom REST handler's top-level
`if data.include?(ISSUE_FIELD_VALUES)` guard then short-circuits — the
key isn't in the payload, so the whole block is skipped and the field
keeps its old value. The MCP tool returns success regardless, so a
coding agent would happily report the field as cleared.
Detect this case in UpdateIssue and fall back to the dedicated REST
DELETE endpoint per field: DELETE /repos/{owner}/{repo}/issues/{number}/
issue-field-values/{field_id}. The endpoint is idempotent, takes the
integer field ID (no GraphQL node ID needed), and is the same one the
public OpenAPI documents.
Empirical confirmation on #2756:
- Set Priority=P1 via REST
- Before the fix: MCP issue_write delete:true returns success, PATCH body
on the wire is literally {}, Priority remains P1 (silent no-op).
- After the fix: MCP issue_write delete:true returns success, PATCH body
is still {}, but the follow-up DELETE clears the field. Priority is
cleared as expected.
Three new tests in issues_delete_test.go cover:
- The omitempty contract (so we know if go-github ever drops the tag)
- The 1-of-1 fallback path (PATCH body has no issue_field_values, DELETE
fires)
- The N-1 set-semantics path (kept list is non-empty, PATCH alone
handles it, no DELETE call)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
8c16c3b to
6a07793
Compare
218443c to
5cd0dff
Compare
Two issues surfaced in code review of the original delete fix:
1. Asking to delete a field that isn't set on the issue used to be a
silent no-op (PATCH body is {} after omitempty stripping, server
skips the issue_field_values block, tool returns success). The fix
turned it into a hard error because the fallback DELETE loop fires
unconditionally for every field ID in fieldIDsToDelete, and the
dotcom DELETE endpoint returns 404 when the field has no value to
delete. This breaks idempotent 'ensure field X is cleared' callers
that may re-run the same delete on a field that's already been
cleared.
Fix: before queueing the fallback DELETEs, filter
fallbackDeleteFieldIDs down to IDs that actually appeared in the
existing field values. This preserves the pre-fix silent-no-op
behaviour and avoids a guaranteed 404.
2. The DELETE loop returned on the first error. If a caller asks to
clear three fields and the second fails (transient 5xx, rate limit,
etc.), the first is gone, the third is never attempted, and the
user gets a generic error with no indication of which field failed
or which had already succeeded. This is unrecoverable from the
caller side.
Fix: continue on per-field errors, accumulate the failed and
succeeded IDs, and return a single aggregated error naming both
sets so callers can retry the right fields.
Tests:
- Test_UpdateIssue_DeleteAbsentFieldIsNoOp: existing field values is
empty, fieldIDsToDelete=[101]. Asserts no DELETE call fires (the
mock returns 404 if it does, which would fail the test) and the
tool returns success.
- Test_UpdateIssue_DeleteFallbackContinuesOnPartialFailure: three
fields exist, all three are deleted. Mock returns 500 for the
middle DELETE, 204 for the others. Asserts all three DELETEs
fired (the middle failure didn't short-circuit the third) and the
error result names failed=[202] and cleared=[101 303].
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
b399b6c to
3b22154
Compare
5cd0dff to
995419a
Compare
Trim the doc comments on UpdateIssue's omitempty-trap region + test file to the bare minimum needed to understand the non-obvious behaviour (why the DELETE fallback exists, why we filter to existing IDs, why errors are aggregated). Drop restated context and step-by-step explanations. No behaviour change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
995419a to
a02f4d3
Compare
Multi-select issue fields ride on the existing custom-fields surface: - issue_write (consolidated) and set_issue_fields (granular) gain multi-select inputs (field_option_names / multi_select_option_ids) - list_issues field_filters gain a 'values' slot for multi-select filtering with AND semantics - list_issue_fields advertises multi_select in its description and surfaces multi-select definitions - Read paths (IssueFieldValueFragment, list_issues enrichment, etc.) decode multi-select values when an org has them The write surface is gated behind a new FF remote_mcp_issue_fields_multiselect. When the flag is off, the legacy variants of issue_write, list_issues, and list_issue_fields are served — same handler bodies, but their schemas and descriptions omit multi_select. Read paths stay unchanged: orgs that have dotcom-side multi-select enabled continue to see multi-select VALUES surfaced, matching what the dotcom UI shows. The granular tools (set_issue_fields) are not separately gated — they are already behind FeatureFlagIssuesGranular, which is itself a user-opt-in rollout flag. Double-gating adds complexity without proportionate benefit for users who have already accepted experimental territory. Per the user's preference for code duplication over interleaved branching: - IssueWrite and IssueWriteLegacy are full siblings (issues.go + issues_legacy_multiselect.go). The shared parser and resolver (optionalIssueWriteFields, resolveIssueRequestFieldValues) take a single multiSelectEnabled bool and reject multi-select inputs/fields when false. - ListIssues / ListIssuesLegacy share a buildListIssues helper that swaps in the right descriptions and adds/removes the field_filters[] values slot. - ListIssueFields / ListIssueFieldsLegacy share a buildListIssueFields helper that swaps the description (handler is identical). Snapshot naming follows the established convention: legacy variants own the canonical <name>.snap; MS-aware variants own <name>_ff_remote_mcp_issue_fields_multiselect.snap. Stacked on #2755 (the universal delete-fix half of the original PR). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Summary
Adds multi-select issue field support across the consolidated and granular issue tools, gated behind a new feature flag
remote_mcp_issue_fields_multiselect. When the flag is off, the existing single-select behaviour is preserved.What's gated behind the FF
field_option_namesslot onissue_fields[]items + multi_select resolverfield_filters[].valuesslot for multi-select option filtering (AND semantics)multi_selectWhat's not gated
FeatureFlagIssuesGranular, no double-gatingIssueFieldValueFragment,list_issuesenrichment, etc.) — orgs with dotcom-side multi-select enabled continue to see multi-select VALUES surfaced, matching the dotcom UIStructure
Per the user's preference, code duplication is preferred over interleaved FF branches:
IssueWrite/IssueWriteLegacyare full siblings (issues.go+issues_legacy_multiselect.go)ListIssues/ListIssuesLegacyshare abuildListIssueshelperListIssueFields/ListIssueFieldsLegacyshare abuildListIssueFieldshelperoptionalIssueWriteFields,resolveIssueRequestFieldValues,parseRawFieldFilters) take a singlemultiSelectEnabled bool— one contained branch point per helper, no FF checks threaded through handler bodiesSnapshot convention: legacy variants own the canonical
<name>.snap; MS-aware own<name>_ff_remote_mcp_issue_fields_multiselect.snap.Stacked on #2755
This PR is stacked on #2755 — Fix delete:true on issue fields by calling deleteIssueFieldValue mutation. Please review and merge that one first.
The original PR contained both the universal delete-bug fix and the multi-select feature; they were split for cleaner review. The base of this PR is set to the delete-fix branch so the diff shown reflects only the multi-select work.