From c78fa8fa2064689ce1d867033720203bc2b1ee5f Mon Sep 17 00:00:00 2001 From: Travis Gockel Date: Thu, 13 Aug 2026 14:28:53 -0600 Subject: [PATCH] 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 dfb823e26b..5642a6f28d 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 77380e5e21..ecbaa7f26f 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)