Skip to content

Commit 2b7807b

Browse files
iulia-bSamMorrowDrums
authored andcommitted
add delete support and merge logic
1 parent 6e19842 commit 2b7807b

4 files changed

Lines changed: 185 additions & 37 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,7 @@ The following sets of tools are available:
855855
- `assignees`: Usernames to assign to this issue (string[], optional)
856856
- `body`: Issue body content (string, optional)
857857
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
858-
- `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional)
858+
- `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)
859859
- `issue_number`: Issue number to update (number, optional)
860860
- `labels`: Labels to apply to this issue (string[], optional)
861861
- `method`: Write operation to perform on a single issue.

docs/feature-flags.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ runtime behavior (such as output formatting) won't appear here.
5656
- `assignees`: Usernames to assign to this issue (string[], optional)
5757
- `body`: Issue body content (string, optional)
5858
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
59-
- `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional)
59+
- `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)
6060
- `issue_number`: Issue number to update (number, optional)
6161
- `labels`: Labels to apply to this issue (string[], optional)
6262
- `method`: Write operation to perform on a single issue.

docs/insiders-features.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
5050
- `assignees`: Usernames to assign to this issue (string[], optional)
5151
- `body`: Issue body content (string, optional)
5252
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
53-
- `issue_fields`: Issue field values to set. Each item requires 'field_name' and exactly one of 'value' or 'field_option_name'. (object[], optional)
53+
- `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)
5454
- `issue_number`: Issue number to update (number, optional)
5555
- `labels`: Labels to apply to this issue (string[], optional)
5656
- `method`: Write operation to perform on a single issue.

pkg/github/issues.go

Lines changed: 182 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type issueWriteFieldInput struct {
4343
FieldName string
4444
Value any
4545
FieldOptionName string
46+
Delete bool
4647
}
4748

4849
const (
@@ -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.
159160
type 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

Comments
 (0)