From 6533daf74a54c9c18959672162093a9f4a0b1ffb Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Thu, 13 Aug 2026 14:28:53 -0600 Subject: [PATCH 1/2] Return assignees from list_issues The list_issues GraphQL fragment never selected assignees, so the tool could not report who an issue was assigned to. Its nearest field, user, is the issue author, which callers conflate with the assignee. Answering "is anything unassigned?" therefore cost one list_issues call plus one issue_read per candidate, and a truncated sweep invites a fabricated answer drawn from the author instead. Add an assignees selection to IssueFragment, flatten it to logins in fragmentToMinimalIssue, and add "assignees" to listIssuesItemFieldEnum so it is selectable through fields. GitHub caps issue assignees at 10, so first: 100 cannot truncate; it also matches the page size already used for assignees in copilot.go. Drop omitempty from MinimalIssue.Assignees and initialize the slice in both converters so an unassigned issue serializes as [] rather than an absent key, which is what lets a caller identify unassigned issues from a single response. This also affects issue_read, the other MinimalIssue consumer, which now reports "assignees": [] instead of omitting the key. --- pkg/github/__toolsnaps__/list_issues.snap | 1 + pkg/github/fields_filtering_test.go | 54 ++++++++++++++++++++++- pkg/github/issues.go | 6 +++ pkg/github/issues_test.go | 27 +++++++++--- pkg/github/minimal_types.go | 17 +++++-- 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 1055fe9947..420577b197 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -50,6 +50,7 @@ "state", "user", "labels", + "assignees", "comments", "created_at", "updated_at", diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index c9dc5de0ee..852b785d10 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -274,7 +274,7 @@ func Test_SearchIssues_FieldsTelemetry(t *testing.T) { // getIssueQueryType; see Test_ListIssues for the canonical copies. const listIssuesFieldsFieldValuesSelection = "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" -const listIssuesFieldsQuery = "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + listIssuesFieldsFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" +const listIssuesFieldsQuery = "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + listIssuesFieldsFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" func listIssuesFieldsMockClient() *http.Client { vars := map[string]any{ @@ -301,6 +301,7 @@ func listIssuesFieldsMockClient() *http.Client { "updatedAt": "2023-01-01T00:00:00Z", "author": map[string]any{"login": "user1"}, "labels": map[string]any{"nodes": []map[string]any{}}, + "assignees": map[string]any{"nodes": []map[string]any{{"login": "octocat"}}}, "comments": map[string]any{"totalCount": 1}, "issueFieldValues": map[string]any{"nodes": []map[string]any{}}, }, @@ -353,6 +354,57 @@ func Test_ListIssues_FieldFiltering(t *testing.T) { assert.NotContains(t, textContent.Text, "\"body\"") } +// Test_ListIssues_AssigneesField covers the assignees field end to end: it is +// selectable via fields, it is dropped when not requested, and it is always +// present in an unfiltered response so that "unassigned" reads as [] rather +// than an absent key. +func Test_ListIssues_AssigneesField(t *testing.T) { + serverTool := ListIssues(translations.NullTranslationHelper) + + callWithFields := func(t *testing.T, fields []any) string { + t.Helper() + deps := BaseDeps{GQLClient: githubv4.NewClient(listIssuesFieldsMockClient())} + handler := serverTool.Handler(deps) + + args := map[string]any{"owner": "owner", "repo": "repo"} + if fields != nil { + args["fields"] = fields + } + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + return getTextResult(t, result).Text + } + + t.Run("selectable via fields", func(t *testing.T) { + var returned struct { + Issues []map[string]any `json:"issues"` + } + require.NoError(t, json.Unmarshal([]byte(callWithFields(t, []any{"number", "assignees"})), &returned)) + require.Len(t, returned.Issues, 1) + require.Len(t, returned.Issues[0], 2, "only the two requested fields should be present") + assert.Equal(t, []any{"octocat"}, returned.Issues[0]["assignees"]) + }) + + t.Run("omitted when not requested", func(t *testing.T) { + text := callWithFields(t, []any{"number", "title"}) + assert.NotContains(t, text, "\"assignees\"") + }) + + t.Run("unassigned issues serialize as an empty array", func(t *testing.T) { + // The mock returns one assigned issue, so drive the empty case through + // the conversion directly: no assignees node must still yield [], never + // null and never an absent key. + issue := fragmentToMinimalIssue(IssueFragment{}) + require.NotNil(t, issue.Assignees) + + encoded, err := json.Marshal(issue) + require.NoError(t, err) + assert.Contains(t, string(encoded), "\"assignees\":[]") + }) +} + func Test_ListIssues_FieldsTelemetry(t *testing.T) { serverTool := ListIssues(translations.NullTranslationHelper) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3f1aaf8489..70b6ecd648 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -499,6 +499,12 @@ type IssueFragment struct { Description githubv4.String } } `graphql:"labels(first: 100)"` + // GitHub caps issue assignees at 10, so first: 100 cannot truncate. + Assignees struct { + Nodes []struct { + Login githubv4.String + } + } `graphql:"assignees(first: 100)"` Comments struct { TotalCount githubv4.Int } `graphql:"comments"` diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index ea1e651466..1909beaa79 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2177,6 +2177,12 @@ func Test_ListIssues(t *testing.T) { {"name": "bug", "id": "label1", "description": "Bug label"}, }, }, + "assignees": map[string]any{ + "nodes": []map[string]any{ + {"login": "octocat"}, + {"login": "mona"}, + }, + }, "comments": map[string]any{ "totalCount": 5, }, @@ -2431,8 +2437,8 @@ func Test_ListIssues(t *testing.T) { // Define the actual query strings that match the implementation issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -2504,6 +2510,17 @@ func Test_ListIssues(t *testing.T) { assert.NotEmpty(t, label, "Label should be a non-empty string") } + // Assignees should be flattened to login strings, and are always + // non-nil so that "unassigned" serializes as [] rather than an + // absent key. Issue #123 has two; #456 and #789 have none. + assert.NotNil(t, issue.Assignees, "Assignees should never be nil") + switch issue.Number { + case 123: + assert.Equal(t, []string{"octocat", "mona"}, issue.Assignees) + default: + assert.Empty(t, issue.Assignees) + } + // Field values should be flattened to {field, value} pairs. Issue #123 has a // SingleSelectValue; issue #456 exercises the Date/Number/Text branches // (including float formatting); #789 has no field values. @@ -2615,8 +2632,8 @@ func Test_ListIssues_FieldFilters(t *testing.T) { ) } - qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" baseVars := func() map[string]any { return map[string]any{ @@ -2977,7 +2994,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } - query := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + query := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},assignees(first: 100){nodes{login}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" vars := map[string]any{ "owner": "octocat", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 2424823c2c..b27f5e4a2e 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -33,9 +33,10 @@ var fileContentFieldEnum = []any{"type", "name", "path", "size", "sha", "url", " // REST conversion sets (for example html_url, reactions, issue_field_values) are // never emitted here and are intentionally omitted. The body and field_values // fields are the heaviest, so omitting them is the main lever for shrinking large -// result sets. +// result sets. Note that user is the issue author; assignees is who it is +// assigned to, and is always present (empty when unassigned). var listIssuesItemFieldEnum = []any{ - "number", "title", "body", "state", "user", "labels", + "number", "title", "body", "state", "user", "labels", "assignees", "comments", "created_at", "updated_at", "field_values", } @@ -543,6 +544,10 @@ type MinimalFieldValue struct { } // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. +// +// Assignees is deliberately not omitempty: both conversions below populate it +// non-nil, so an empty list is a definitive "nobody is assigned" answer rather +// than an absent key. Callers filtering for unassigned issues depend on that. type MinimalIssue struct { Number int `json:"number"` Title string `json:"title"` @@ -555,7 +560,7 @@ type MinimalIssue struct { User *MinimalUser `json:"user,omitempty"` AuthorAssociation string `json:"author_association,omitempty"` Labels []string `json:"labels,omitempty"` - Assignees []string `json:"assignees,omitempty"` + Assignees []string `json:"assignees"` Milestone string `json:"milestone,omitempty"` Comments int `json:"comments,omitempty"` Reactions *MinimalReactions `json:"reactions,omitempty"` @@ -799,6 +804,7 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { } } + m.Assignees = make([]string, 0, len(issue.Assignees)) for _, assignee := range issue.Assignees { if assignee != nil { m.Assignees = append(m.Assignees, assignee.GetLogin()) @@ -872,6 +878,11 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m.Labels = append(m.Labels, string(label.Name)) } + m.Assignees = make([]string, 0, len(fragment.Assignees.Nodes)) + for _, assignee := range fragment.Assignees.Nodes { + m.Assignees = append(m.Assignees, string(assignee.Login)) + } + for _, fv := range fragment.IssueFieldValues.Nodes { if mfv, ok := fragmentToMinimalFieldValue(fv); ok { m.FieldValues = append(m.FieldValues, mfv) From cdec474c2626afa7966489745b6cd8293eb64cdb Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 19 Aug 2026 11:23:55 +0200 Subject: [PATCH 2/2] test(issues): cover stable assignee responses Verify issue_read returns assigned logins and a definitive empty array for unassigned issues. Exercise the same empty-array contract through list_issues field filtering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/fields_filtering_test.go | 7 ++----- pkg/github/issues_test.go | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index 852b785d10..cd08dcef68 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -393,15 +393,12 @@ func Test_ListIssues_AssigneesField(t *testing.T) { }) t.Run("unassigned issues serialize as an empty array", func(t *testing.T) { - // The mock returns one assigned issue, so drive the empty case through - // the conversion directly: no assignees node must still yield [], never - // null and never an absent key. issue := fragmentToMinimalIssue(IssueFragment{}) require.NotNil(t, issue.Assignees) - encoded, err := json.Marshal(issue) + filtered, err := filterFields(issue, []string{"assignees"}) require.NoError(t, err) - assert.Contains(t, string(encoded), "\"assignees\":[]") + assert.Equal(t, map[string]any{"assignees": []any{}}, filtered) }) } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 1909beaa79..035c7f6720 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -144,6 +144,10 @@ func Test_GetIssue(t *testing.T) { User: &github.User{ Login: github.Ptr("testuser"), }, + Assignees: []*github.User{ + {Login: github.Ptr("octocat")}, + {Login: github.Ptr("mona")}, + }, Repository: &github.Repository{ Name: github.Ptr("repo"), Owner: &github.User{ @@ -287,6 +291,19 @@ func Test_GetIssue(t *testing.T) { assert.Equal(t, tc.expectedIssue.GetState(), returnedIssue.State) assert.Equal(t, tc.expectedIssue.GetHTMLURL(), returnedIssue.HTMLURL) assert.Equal(t, tc.expectedIssue.GetUser().GetLogin(), returnedIssue.User.Login) + + expectedAssignees := make([]string, 0, len(tc.expectedIssue.Assignees)) + for _, assignee := range tc.expectedIssue.Assignees { + expectedAssignees = append(expectedAssignees, assignee.GetLogin()) + } + assert.Equal(t, expectedAssignees, returnedIssue.Assignees) + + var rawIssue map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &rawIssue)) + require.Contains(t, rawIssue, "assignees") + if len(expectedAssignees) == 0 { + assert.JSONEq(t, "[]", string(rawIssue["assignees"])) + } }) } }