Skip to content

Commit ae176ea

Browse files
owenniblockCopilot
andcommitted
Polish issue_fields write path: clearer empty-array error, drop dead 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>
1 parent 2d09359 commit ae176ea

2 files changed

Lines changed: 152 additions & 7 deletions

File tree

pkg/github/issues.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,8 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro
295295
}
296296

297297
var fieldOptionNames []string
298-
if rawNames, hasNames := itemMap["field_option_names"]; hasNames && rawNames != nil {
298+
_, hasNamesKey := itemMap["field_option_names"]
299+
if rawNames := itemMap["field_option_names"]; hasNamesKey && rawNames != nil {
299300
switch v := rawNames.(type) {
300301
case []string:
301302
fieldOptionNames = v
@@ -341,6 +342,9 @@ func optionalIssueWriteFields(args map[string]any) ([]issueWriteFieldInput, erro
341342
setters++
342343
}
343344
if setters == 0 {
345+
if hasNamesKey {
346+
return nil, fmt.Errorf("issue field %q has empty field_option_names — use 'delete: true' to clear the field", fieldName)
347+
}
344348
return nil, fmt.Errorf("issue field %q must specify one of value, field_option_name, or field_option_names", fieldName)
345349
}
346350
if setters > 1 {
@@ -688,15 +692,14 @@ type ListIssuesQueryTypeWithLabelsWithSince struct {
688692

689693
// IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value
690694
// field should be set per filter (the monolith resolver rejects multiple). For multi_select fields,
691-
// MultiSelectOptionValues / MultiSelectOptionIDs use all-of (AND) semantics: an issue matches only
692-
// if it has every listed option set on the field. To match any-of, send separate filter calls.
695+
// MultiSelectOptionValues uses all-of (AND) semantics: an issue matches only if it has every listed
696+
// option set on the field. To match any-of, send separate filter calls.
693697
type IssueFieldValueFilter struct {
694698
FieldName githubv4.String `json:"fieldName"`
695699
TextValue *githubv4.String `json:"textValue,omitempty"`
696700
DateValue *githubv4.String `json:"dateValue,omitempty"`
697701
NumberValue *githubv4.Float `json:"numberValue,omitempty"`
698702
SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"`
699-
MultiSelectOptionIDs *[]githubv4.ID `json:"multiSelectOptionIds,omitempty"`
700703
MultiSelectOptionValues *[]githubv4.String `json:"multiSelectOptionValues,omitempty"`
701704
}
702705

pkg/github/issues_multiselect_test.go

Lines changed: 145 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,9 +243,10 @@ func Test_optionalIssueWriteFields_MultiSelect(t *testing.T) {
243243
},
244244
})
245245
require.Error(t, err)
246-
// An empty slice is treated the same as "neither value/option provided" — the
247-
// caller has to pick something. delete:true is the right way to clear a field.
248-
assert.Contains(t, err.Error(), "must specify one of")
246+
// An empty slice is a common "clear the field" guess; nudge callers to delete:true
247+
// so the GraphQL deletion mutation runs instead of an unintentional no-op.
248+
assert.Contains(t, err.Error(), "empty field_option_names")
249+
assert.Contains(t, err.Error(), "delete: true")
249250
})
250251

251252
t.Run("rejects field_option_names + value combo", func(t *testing.T) {
@@ -528,3 +529,144 @@ func Test_UpdateIssue_DeleteFieldValueRunsGraphQLMutation(t *testing.T) {
528529
}
529530

530531
// (intentionally no further helpers)
532+
533+
// Test_UpdateIssue_DeleteAndSetFieldsInSameCall verifies that a single UpdateIssue
534+
// can delete one issue field while setting another in the same request: the REST PATCH
535+
// must carry only the set operation (no null clearing for the deleted field), and the
536+
// deleteIssueFieldValue GraphQL mutation must fire for the deletion.
537+
func Test_UpdateIssue_DeleteAndSetFieldsInSameCall(t *testing.T) {
538+
t.Parallel()
539+
540+
mockIssue := &gogithub.Issue{
541+
Number: gogithub.Ptr(42),
542+
Title: gogithub.Ptr("Test issue"),
543+
State: gogithub.Ptr("open"),
544+
HTMLURL: gogithub.Ptr("https://github.com/owner/repo/issues/42"),
545+
}
546+
547+
// Verify the PATCH body carries only the kept set operation. Crucially, it must
548+
// NOT include the deleted Components field (omitting != sending null), and it
549+
// must NOT double-clear the deleted field — the GraphQL mutation owns that path.
550+
restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
551+
PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{
552+
"issue_field_values": []any{
553+
map[string]any{"field_id": float64(202), "value": "P2"},
554+
},
555+
}).andThen(
556+
mockResponse(t, http.StatusOK, mockIssue),
557+
),
558+
}))
559+
560+
// Existing issue carries both fields. After merge + deletion filter, only the
561+
// Priority "set" entry should remain in the REST payload.
562+
existingFieldsResponse := githubv4mock.DataResponse(map[string]any{
563+
"repository": map[string]any{
564+
"issue": map[string]any{
565+
"issueFieldValues": map[string]any{
566+
"nodes": []any{
567+
map[string]any{
568+
"__typename": "IssueFieldMultiSelectValue",
569+
"field": map[string]any{
570+
"fullDatabaseId": "101",
571+
"name": "Components",
572+
},
573+
"options": []any{
574+
map[string]any{"name": "Auth"},
575+
map[string]any{"name": "Billing"},
576+
},
577+
},
578+
map[string]any{
579+
"__typename": "IssueFieldSingleSelectValue",
580+
"field": map[string]any{
581+
"fullDatabaseId": "202",
582+
"name": "Priority",
583+
},
584+
"value": "P1",
585+
},
586+
},
587+
},
588+
},
589+
},
590+
})
591+
592+
issueIDResponse := githubv4mock.DataResponse(map[string]any{
593+
"repository": map[string]any{
594+
"issue": map[string]any{"id": "I_node_42"},
595+
},
596+
})
597+
598+
// The matcher fails the test if the mutation doesn't fire with the right
599+
// (issueId, fieldId) pair — proves the delete side of the combined update ran.
600+
deleteResponse := githubv4mock.DataResponse(map[string]any{
601+
"deleteIssueFieldValue": map[string]any{
602+
"issue": map[string]any{"number": 42},
603+
},
604+
})
605+
606+
gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(
607+
githubv4mock.NewQueryMatcher(
608+
struct {
609+
Repository struct {
610+
Issue struct {
611+
IssueFieldValues struct {
612+
Nodes []IssueFieldValueFragment
613+
} `graphql:"issueFieldValues(first: 25)"`
614+
} `graphql:"issue(number: $number)"`
615+
} `graphql:"repository(owner: $owner, name: $repo)"`
616+
}{},
617+
map[string]any{
618+
"owner": githubv4.String("owner"),
619+
"repo": githubv4.String("repo"),
620+
"number": githubv4.Int(42),
621+
},
622+
existingFieldsResponse,
623+
),
624+
githubv4mock.NewQueryMatcher(
625+
struct {
626+
Repository struct {
627+
Issue struct {
628+
ID githubv4.ID
629+
} `graphql:"issue(number: $issueNumber)"`
630+
} `graphql:"repository(owner: $owner, name: $repo)"`
631+
}{},
632+
map[string]any{
633+
"owner": githubv4.String("owner"),
634+
"repo": githubv4.String("repo"),
635+
"issueNumber": githubv4.Int(42),
636+
},
637+
issueIDResponse,
638+
),
639+
githubv4mock.NewMutationMatcher(
640+
struct {
641+
DeleteIssueFieldValue struct {
642+
Issue struct {
643+
Number githubv4.Int
644+
}
645+
} `graphql:"deleteIssueFieldValue(input: $input)"`
646+
}{},
647+
DeleteIssueFieldValueInput{
648+
IssueID: githubv4.ID("I_node_42"),
649+
FieldID: githubv4.ID("IFMS_node_101"),
650+
},
651+
nil,
652+
deleteResponse,
653+
),
654+
))
655+
656+
result, err := UpdateIssue(
657+
context.Background(),
658+
restClient,
659+
gqlClient,
660+
"owner", "repo", 42,
661+
"", "", nil, nil, 0, "",
662+
[]*gogithub.IssueRequestFieldValue{
663+
{FieldID: 202, Value: "P2"},
664+
},
665+
[]fieldDeletion{{DatabaseID: 101, NodeID: githubv4.ID("IFMS_node_101")}},
666+
"", "", 0,
667+
)
668+
require.NoError(t, err)
669+
if result.IsError {
670+
t.Fatalf("expected non-error result, got: %s", getTextResult(t, result).Text)
671+
}
672+
}

0 commit comments

Comments
 (0)