@@ -43,6 +43,7 @@ type issueWriteFieldInput struct {
4343 FieldName string
4444 Value any
4545 FieldOptionName string
46+ Delete bool
4647}
4748
4849const (
@@ -157,10 +158,22 @@ type issueFieldWriteMetadataQuery struct {
157158// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText,
158159// so we have to ask for `name` on each member.
159160type IssueFieldRef struct {
160- Date struct { Name githubv4.String } `graphql:"... on IssueFieldDate"`
161- Number struct { Name githubv4.String } `graphql:"... on IssueFieldNumber"`
162- SingleSelect struct { Name githubv4.String } `graphql:"... on IssueFieldSingleSelect"`
163- Text struct { Name githubv4.String } `graphql:"... on IssueFieldText"`
161+ Date struct {
162+ Name githubv4.String
163+ FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
164+ } `graphql:"... on IssueFieldDate"`
165+ Number struct {
166+ Name githubv4.String
167+ FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
168+ } `graphql:"... on IssueFieldNumber"`
169+ SingleSelect struct {
170+ Name githubv4.String
171+ FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
172+ } `graphql:"... on IssueFieldSingleSelect"`
173+ Text struct {
174+ Name githubv4.String
175+ FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
176+ } `graphql:"... on IssueFieldText"`
164177}
165178
166179// Name returns the populated name from whichever IssueFields union variant the field resolved to.
@@ -178,6 +191,22 @@ func (r IssueFieldRef) Name() string {
178191 return ""
179192}
180193
194+ // FullDatabaseIDStr returns the fullDatabaseId string from whichever IssueFields union variant
195+ // the field resolved to.
196+ func (r IssueFieldRef ) FullDatabaseIDStr () string {
197+ switch {
198+ case r .Date .FullDatabaseID != "" :
199+ return string (r .Date .FullDatabaseID )
200+ case r .Number .FullDatabaseID != "" :
201+ return string (r .Number .FullDatabaseID )
202+ case r .SingleSelect .FullDatabaseID != "" :
203+ return string (r .SingleSelect .FullDatabaseID )
204+ case r .Text .FullDatabaseID != "" :
205+ return string (r .Text .FullDatabaseID )
206+ }
207+ return ""
208+ }
209+
181210// IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union
182211// of 4 concrete value types; each carries its own value scalar and a reference to its parent field.
183212// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode.
@@ -235,11 +264,23 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro
235264 return nil , err
236265 }
237266
267+ deleteField , _ := OptionalParam [bool ](itemMap , "delete" )
238268 value , hasValue := itemMap ["value" ]
239269 if hasValue && value == nil {
240270 return nil , fmt .Errorf ("value cannot be null for field %q" , fieldName )
241271 }
242272
273+ if deleteField {
274+ if hasValue || fieldOptionName != "" {
275+ return nil , fmt .Errorf ("issue field %q cannot specify 'delete' together with 'value' or 'field_option_name'" , fieldName )
276+ }
277+ issueFields = append (issueFields , issueWriteFieldInput {
278+ FieldName : fieldName ,
279+ Delete : true ,
280+ })
281+ continue
282+ }
283+
243284 if hasValue && fieldOptionName != "" {
244285 return nil , fmt .Errorf ("issue field %q cannot specify both value and field_option_name" , fieldName )
245286 }
@@ -258,9 +299,9 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro
258299 return issueFields , nil
259300}
260301
261- func resolveIssueRequestFieldValues (ctx context.Context , gqlClient * githubv4.Client , owner , repo string , issueFields []issueWriteFieldInput ) ([]* github.IssueRequestFieldValue , error ) {
302+ func resolveIssueRequestFieldValues (ctx context.Context , gqlClient * githubv4.Client , owner , repo string , issueFields []issueWriteFieldInput ) ([]* github.IssueRequestFieldValue , [] int64 , error ) {
262303 if len (issueFields ) == 0 {
263- return nil , nil
304+ return nil , nil , nil
264305 }
265306
266307 ctxWithFeatures := ghcontext .WithGraphQLFeatures (ctx , "issue_fields" , "repo_issue_fields" )
@@ -270,7 +311,7 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
270311 "repo" : githubv4 .String (repo ),
271312 }
272313 if err := gqlClient .Query (ctxWithFeatures , & query , vars ); err != nil {
273- return nil , fmt .Errorf ("failed to query issue fields metadata: %w" , err )
314+ return nil , nil , fmt .Errorf ("failed to query issue fields metadata: %w" , err )
274315 }
275316
276317 // Build name → node map, dispatching on concrete type to extract name.
@@ -293,10 +334,11 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
293334 }
294335
295336 resolved := make ([]* github.IssueRequestFieldValue , 0 , len (issueFields ))
337+ var fieldIDsToDelete []int64
296338 for _ , fieldInput := range issueFields {
297339 node , ok := fieldByName [strings .ToLower (strings .TrimSpace (fieldInput .FieldName ))]
298340 if ! ok {
299- return nil , fmt .Errorf ("issue field %q was not found in %s/%s" , fieldInput .FieldName , owner , repo )
341+ return nil , nil , fmt .Errorf ("issue field %q was not found in %s/%s" , fieldInput .FieldName , owner , repo )
300342 }
301343
302344 var fullDatabaseIDStr , dataType string
@@ -317,13 +359,18 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
317359
318360 fieldID := parseFullDatabaseID (fullDatabaseIDStr )
319361 if fieldID == 0 {
320- return nil , fmt .Errorf ("issue field %q is missing fullDatabaseId" , fieldInput .FieldName )
362+ return nil , nil , fmt .Errorf ("issue field %q is missing fullDatabaseId" , fieldInput .FieldName )
363+ }
364+
365+ if fieldInput .Delete {
366+ fieldIDsToDelete = append (fieldIDsToDelete , fieldID )
367+ continue
321368 }
322369
323370 resolvedValue := fieldInput .Value
324371 if fieldInput .FieldOptionName != "" {
325372 if ! strings .EqualFold (dataType , "single_select" ) {
326- return nil , fmt .Errorf ("issue field %q is %q, so field_option_name cannot be used" , fieldInput .FieldName , dataType )
373+ return nil , nil , fmt .Errorf ("issue field %q is %q, so field_option_name cannot be used" , fieldInput .FieldName , dataType )
327374 }
328375
329376 optionFound := false
@@ -337,7 +384,7 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
337384 }
338385
339386 if ! optionFound {
340- return nil , fmt .Errorf ("issue field option %q was not found for field %q" , fieldInput .FieldOptionName , fieldInput .FieldName )
387+ return nil , nil , fmt .Errorf ("issue field option %q was not found for field %q" , fieldInput .FieldOptionName , fieldInput .FieldName )
341388 }
342389 }
343390
@@ -347,7 +394,85 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli
347394 })
348395 }
349396
350- return resolved , nil
397+ return resolved , fieldIDsToDelete , nil
398+ }
399+
400+ // fetchExistingIssueFieldValues retrieves the current field values for an issue
401+ // as IssueRequestFieldValue entries, ready to be merged before an update.
402+ func fetchExistingIssueFieldValues (ctx context.Context , gqlClient * githubv4.Client , owner , repo string , issueNumber int ) ([]* github.IssueRequestFieldValue , error ) {
403+ ctxWithFeatures := ghcontext .WithGraphQLFeatures (ctx , "issue_fields" , "repo_issue_fields" )
404+
405+ var query struct {
406+ Repository struct {
407+ Issue struct {
408+ IssueFieldValues struct {
409+ Nodes []IssueFieldValueFragment
410+ } `graphql:"issueFieldValues(first: 25)"`
411+ } `graphql:"issue(number: $number)"`
412+ } `graphql:"repository(owner: $owner, name: $repo)"`
413+ }
414+
415+ vars := map [string ]any {
416+ "owner" : githubv4 .String (owner ),
417+ "repo" : githubv4 .String (repo ),
418+ "number" : githubv4 .Int (issueNumber ), // #nosec G115 - issue numbers are always small positive integers
419+ }
420+
421+ if err := gqlClient .Query (ctxWithFeatures , & query , vars ); err != nil {
422+ return nil , fmt .Errorf ("failed to fetch existing issue field values: %w" , err )
423+ }
424+
425+ var result []* github.IssueRequestFieldValue
426+ for _ , node := range query .Repository .Issue .IssueFieldValues .Nodes {
427+ var fieldIDStr string
428+ var value any
429+
430+ switch node .TypeName {
431+ case "IssueFieldDateValue" :
432+ fieldIDStr = node .DateValue .Field .FullDatabaseIDStr ()
433+ value = string (node .DateValue .Value )
434+ case "IssueFieldNumberValue" :
435+ fieldIDStr = node .NumberValue .Field .FullDatabaseIDStr ()
436+ value = float64 (node .NumberValue .Value )
437+ case "IssueFieldSingleSelectValue" :
438+ fieldIDStr = node .SingleSelectValue .Field .FullDatabaseIDStr ()
439+ value = string (node .SingleSelectValue .Value )
440+ case "IssueFieldTextValue" :
441+ fieldIDStr = node .TextValue .Field .FullDatabaseIDStr ()
442+ value = string (node .TextValue .Value )
443+ default :
444+ continue
445+ }
446+
447+ fieldID := parseFullDatabaseID (fieldIDStr )
448+ if fieldID == 0 {
449+ continue
450+ }
451+
452+ result = append (result , & github.IssueRequestFieldValue {
453+ FieldID : fieldID ,
454+ Value : value ,
455+ })
456+ }
457+
458+ return result , nil
459+ }
460+
461+ // mergeIssueFieldValues returns a merged slice where incoming values override existing ones
462+ // for the same field ID, and existing fields not present in incoming are preserved.
463+ func mergeIssueFieldValues (existing , incoming []* github.IssueRequestFieldValue ) []* github.IssueRequestFieldValue {
464+ merged := make (map [int64 ]* github.IssueRequestFieldValue , len (existing )+ len (incoming ))
465+ for _ , v := range existing {
466+ merged [v .FieldID ] = v
467+ }
468+ for _ , v := range incoming {
469+ merged [v .FieldID ] = v
470+ }
471+ result := make ([]* github.IssueRequestFieldValue , 0 , len (merged ))
472+ for _ , v := range merged {
473+ result = append (result , v )
474+ }
475+ return result
351476}
352477
353478// IssueFragment represents a fragment of an issue node in the GraphQL API.
@@ -1719,35 +1844,37 @@ Options are:
17191844 },
17201845 "issue_fields" : {
17211846 Type : "array" ,
1722- Description : "Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name '." ,
1847+ Description : "Issue field values to set or clear . Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true '." ,
17231848 Items : & jsonschema.Schema {
17241849 Type : "object" ,
17251850 AdditionalProperties : & jsonschema.Schema {Not : & jsonschema.Schema {}},
17261851 Properties : map [string ]* jsonschema.Schema {
17271852 "field_name" : {
1728- Type : "string" ,
1729- Description : "Issue field name" ,
1853+ Type : "string" ,
1854+ Description : "Issue field name (case-insensitive). Must match a field " +
1855+ "returned by list_issue_fields for this repository or its organization." ,
17301856 },
17311857 "value" : {
1732- Types : []string {"string" , "number" , "boolean" },
1733- Description : "Value to set. For single-select fields, prefer 'field_option_name' to validate the option exists first." ,
1858+ Types : []string {"string" , "number" , "boolean" },
1859+ Description : "Value to set. Use for text, number, and date fields " +
1860+ "(date as YYYY-MM-DD). For single-select fields, prefer " +
1861+ "'field_option_name' so the option is validated before the API " +
1862+ "call. Cannot be combined with 'field_option_name' or 'delete'." ,
17341863 },
17351864 "field_option_name" : {
1736- Type : "string" ,
1737- Description : "Option name for single-select fields — validates the option exists in the field definition before setting it." ,
1738- },
1739- },
1740- Required : []string {"field_name" },
1741- OneOf : []* jsonschema.Schema {
1742- {
1743- Required : []string {"value" },
1744- Not : & jsonschema.Schema {Required : []string {"field_option_name" }},
1865+ Type : "string" ,
1866+ Description : "Option name for single-select fields. Validated against " +
1867+ "the field's options before the API call. Cannot be combined with " +
1868+ "'value' or 'delete'." ,
17451869 },
1746- {
1747- Required : []string {"field_option_name" },
1748- Not : & jsonschema.Schema {Required : []string {"value" }},
1870+ "delete" : {
1871+ Type : "boolean" ,
1872+ Enum : []any {true },
1873+ Description : "Set to true to clear this field's current value on the " +
1874+ "issue. Cannot be combined with 'value' or 'field_option_name'." ,
17491875 },
17501876 },
1877+ Required : []string {"field_name" },
17511878 },
17521879 },
17531880 },
@@ -1866,7 +1993,7 @@ Options are:
18661993 return utils .NewToolResultErrorFromErr ("failed to get GraphQL client" , err ), nil , nil
18671994 }
18681995
1869- issueFieldValues , err := resolveIssueRequestFieldValues (ctx , gqlClient , owner , repo , issueFields )
1996+ issueFieldValues , fieldIDsToDelete , err := resolveIssueRequestFieldValues (ctx , gqlClient , owner , repo , issueFields )
18701997 if err != nil {
18711998 return utils .NewToolResultError (fmt .Sprintf ("failed to resolve issue_fields: %v" , err )), nil , nil
18721999 }
@@ -1880,7 +2007,7 @@ Options are:
18802007 if err != nil {
18812008 return utils .NewToolResultError (err .Error ()), nil , nil
18822009 }
1883- result , err := UpdateIssue (ctx , client , gqlClient , owner , repo , issueNumber , title , body , assignees , labels , milestoneNum , issueType , issueFieldValues , state , stateReason , duplicateOf )
2010+ result , err := UpdateIssue (ctx , client , gqlClient , owner , repo , issueNumber , title , body , assignees , labels , milestoneNum , issueType , issueFieldValues , fieldIDsToDelete , state , stateReason , duplicateOf )
18842011 return result , nil , err
18852012 default :
18862013 return utils .NewToolResultError ("invalid method, must be either 'create' or 'update'" ), nil , nil
@@ -1944,7 +2071,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo
19442071 return utils .NewToolResultText (string (r )), nil
19452072}
19462073
1947- func UpdateIssue (ctx context.Context , client * github.Client , gqlClient * githubv4.Client , owner string , repo string , issueNumber int , title string , body string , assignees []string , labels []string , milestoneNum int , issueType string , issueFieldValues []* github.IssueRequestFieldValue , state string , stateReason string , duplicateOf int ) (* mcp.CallToolResult , error ) {
2074+ func UpdateIssue (ctx context.Context , client * github.Client , gqlClient * githubv4.Client , owner string , repo string , issueNumber int , title string , body string , assignees []string , labels []string , milestoneNum int , issueType string , issueFieldValues []* github.IssueRequestFieldValue , fieldIDsToDelete [] int64 , state string , stateReason string , duplicateOf int ) (* mcp.CallToolResult , error ) {
19482075 // Create the issue request with only provided fields
19492076 issueRequest := & github.IssueRequest {}
19502077
@@ -1973,8 +2100,29 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4
19732100 issueRequest .Type = github .Ptr (issueType )
19742101 }
19752102
1976- if len (issueFieldValues ) > 0 {
1977- issueRequest .IssueFieldValues = issueFieldValues
2103+ if len (issueFieldValues ) > 0 || len (fieldIDsToDelete ) > 0 {
2104+ // The REST update endpoint uses "set" semantics — it overwrites all existing
2105+ // field values with whatever is sent. Fetch the current values first, merge in
2106+ // the new values, then remove any explicitly deleted fields.
2107+ existing , err := fetchExistingIssueFieldValues (ctx , gqlClient , owner , repo , issueNumber )
2108+ if err != nil {
2109+ return ghErrors .NewGitHubGraphQLErrorResponse (ctx , "failed to fetch existing issue field values" , err ), nil
2110+ }
2111+ merged := mergeIssueFieldValues (existing , issueFieldValues )
2112+ if len (fieldIDsToDelete ) > 0 {
2113+ deleteSet := make (map [int64 ]bool , len (fieldIDsToDelete ))
2114+ for _ , id := range fieldIDsToDelete {
2115+ deleteSet [id ] = true
2116+ }
2117+ kept := make ([]* github.IssueRequestFieldValue , 0 , len (merged ))
2118+ for _ , v := range merged {
2119+ if ! deleteSet [v .FieldID ] {
2120+ kept = append (kept , v )
2121+ }
2122+ }
2123+ merged = kept
2124+ }
2125+ issueRequest .IssueFieldValues = merged
19782126 }
19792127
19802128 updatedIssue , resp , err := client .Issues .Edit (ctx , owner , repo , issueNumber , issueRequest )
0 commit comments