Skip to content

Commit cc2a957

Browse files
Include custom issue field values in list_issues response (#2466)
* Include custom issue field values in list_issues response Adds Issues 2.0 custom field values to each issue returned by the list_issues GraphQL query, exposed on MinimalIssue as field_values: [{field, value}]. Filtering by field is a separate concern (needs the GraphQL IssueFilters input updated upstream) and is not included here. shurcooL/graphql's response decoder walks every inline fragment of a union regardless of __typename, so IssueFieldNumberValue.value is aliased to valueNumber to avoid a Float-vs-String type clash when the runtime variant is, e.g., a SingleSelectValue. * Extend list_issues tests to cover Date/Number/Text field value variants --------- Co-authored-by: Sam Morrow <info@sam-morrow.com>
1 parent b2b4936 commit cc2a957

3 files changed

Lines changed: 166 additions & 23 deletions

File tree

pkg/github/issues.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,54 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
103103
}
104104
}
105105

106+
// IssueFieldRef resolves the name of an issue field across its concrete types.
107+
// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText,
108+
// so we have to ask for `name` on each member.
109+
type IssueFieldRef struct {
110+
Date struct{ Name githubv4.String } `graphql:"... on IssueFieldDate"`
111+
Number struct{ Name githubv4.String } `graphql:"... on IssueFieldNumber"`
112+
SingleSelect struct{ Name githubv4.String } `graphql:"... on IssueFieldSingleSelect"`
113+
Text struct{ Name githubv4.String } `graphql:"... on IssueFieldText"`
114+
}
115+
116+
// Name returns the populated name from whichever IssueFields union variant the field resolved to.
117+
func (r IssueFieldRef) Name() string {
118+
switch {
119+
case r.Date.Name != "":
120+
return string(r.Date.Name)
121+
case r.Number.Name != "":
122+
return string(r.Number.Name)
123+
case r.SingleSelect.Name != "":
124+
return string(r.SingleSelect.Name)
125+
case r.Text.Name != "":
126+
return string(r.Text.Name)
127+
}
128+
return ""
129+
}
130+
131+
// IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union
132+
// of 4 concrete value types; each carries its own value scalar and a reference to its parent field.
133+
// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode.
134+
type IssueFieldValueFragment struct {
135+
TypeName string `graphql:"__typename"`
136+
DateValue struct {
137+
Field IssueFieldRef
138+
Value githubv4.String
139+
} `graphql:"... on IssueFieldDateValue"`
140+
NumberValue struct {
141+
Field IssueFieldRef
142+
Value githubv4.Float `graphql:"valueNumber: value"`
143+
} `graphql:"... on IssueFieldNumberValue"`
144+
SingleSelectValue struct {
145+
Field IssueFieldRef
146+
Value githubv4.String
147+
} `graphql:"... on IssueFieldSingleSelectValue"`
148+
TextValue struct {
149+
Field IssueFieldRef
150+
Value githubv4.String
151+
} `graphql:"... on IssueFieldTextValue"`
152+
}
153+
106154
// IssueFragment represents a fragment of an issue node in the GraphQL API.
107155
type IssueFragment struct {
108156
Number githubv4.Int
@@ -126,6 +174,9 @@ type IssueFragment struct {
126174
Comments struct {
127175
TotalCount githubv4.Int
128176
} `graphql:"comments"`
177+
IssueFieldValues struct {
178+
Nodes []IssueFieldValueFragment
179+
} `graphql:"issueFieldValues(first: 25)"`
129180
}
130181

131182
// Common interface for all issue query types

pkg/github/issues_test.go

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1345,6 +1345,15 @@ func Test_ListIssues(t *testing.T) {
13451345
"comments": map[string]any{
13461346
"totalCount": 5,
13471347
},
1348+
"issueFieldValues": map[string]any{
1349+
"nodes": []map[string]any{
1350+
{
1351+
"__typename": "IssueFieldSingleSelectValue",
1352+
"field": map[string]any{"name": "priority"},
1353+
"value": "P1",
1354+
},
1355+
},
1356+
},
13481357
},
13491358
{
13501359
"number": 456,
@@ -1363,6 +1372,25 @@ func Test_ListIssues(t *testing.T) {
13631372
"comments": map[string]any{
13641373
"totalCount": 3,
13651374
},
1375+
"issueFieldValues": map[string]any{
1376+
"nodes": []map[string]any{
1377+
{
1378+
"__typename": "IssueFieldDateValue",
1379+
"field": map[string]any{"name": "due"},
1380+
"value": "2026-06-01",
1381+
},
1382+
{
1383+
"__typename": "IssueFieldNumberValue",
1384+
"field": map[string]any{"name": "estimate"},
1385+
"valueNumber": 2.5,
1386+
},
1387+
{
1388+
"__typename": "IssueFieldTextValue",
1389+
"field": map[string]any{"name": "notes"},
1390+
"value": "needs triage",
1391+
},
1392+
},
1393+
},
13661394
},
13671395
}
13681396

@@ -1383,6 +1411,9 @@ func Test_ListIssues(t *testing.T) {
13831411
"comments": map[string]any{
13841412
"totalCount": 1,
13851413
},
1414+
"issueFieldValues": map[string]any{
1415+
"nodes": []map[string]any{},
1416+
},
13861417
},
13871418
}
13881419

@@ -1557,8 +1588,9 @@ func Test_ListIssues(t *testing.T) {
15571588
}
15581589

15591590
// Define the actual query strings that match the implementation
1560-
qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
1561-
qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
1591+
issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}"
1592+
qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){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}}"
1593+
qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$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}){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}}"
15621594

15631595
for _, tc := range tests {
15641596
t.Run(tc.name, func(t *testing.T) {
@@ -1629,6 +1661,22 @@ func Test_ListIssues(t *testing.T) {
16291661
for _, label := range issue.Labels {
16301662
assert.NotEmpty(t, label, "Label should be a non-empty string")
16311663
}
1664+
1665+
// Field values should be flattened to {field, value} pairs. Issue #123 has a
1666+
// SingleSelectValue; issue #456 exercises the Date/Number/Text branches
1667+
// (including float formatting); #789 has no field values.
1668+
switch issue.Number {
1669+
case 123:
1670+
assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues)
1671+
case 456:
1672+
assert.Equal(t, []MinimalIssueFieldValue{
1673+
{Field: "due", Value: "2026-06-01"},
1674+
{Field: "estimate", Value: "2.5"},
1675+
{Field: "notes", Value: "needs triage"},
1676+
}, issue.FieldValues)
1677+
default:
1678+
assert.Empty(t, issue.FieldValues)
1679+
}
16321680
}
16331681
})
16341682
}
@@ -1674,7 +1722,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
16741722
})
16751723
}
16761724

1677-
query := "query($after:String$direction:OrderDirection!$first:Int!$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}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
1725+
query := "query($after:String$direction:OrderDirection!$first:Int!$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}){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},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}"
16781726

16791727
vars := map[string]any{
16801728
"owner": "octocat",

pkg/github/minimal_types.go

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package github
22

33
import (
4+
"strconv"
45
"time"
56

67
"github.com/google/go-github/v87/github"
@@ -203,26 +204,35 @@ type MinimalReactions struct {
203204

204205
// MinimalIssue is the trimmed output type for issue objects to reduce verbosity.
205206
type MinimalIssue struct {
206-
Number int `json:"number"`
207-
Title string `json:"title"`
208-
Body string `json:"body,omitempty"`
209-
State string `json:"state"`
210-
StateReason string `json:"state_reason,omitempty"`
211-
Draft bool `json:"draft,omitempty"`
212-
Locked bool `json:"locked,omitempty"`
213-
HTMLURL string `json:"html_url,omitempty"`
214-
User *MinimalUser `json:"user,omitempty"`
215-
AuthorAssociation string `json:"author_association,omitempty"`
216-
Labels []string `json:"labels,omitempty"`
217-
Assignees []string `json:"assignees,omitempty"`
218-
Milestone string `json:"milestone,omitempty"`
219-
Comments int `json:"comments,omitempty"`
220-
Reactions *MinimalReactions `json:"reactions,omitempty"`
221-
CreatedAt string `json:"created_at,omitempty"`
222-
UpdatedAt string `json:"updated_at,omitempty"`
223-
ClosedAt string `json:"closed_at,omitempty"`
224-
ClosedBy string `json:"closed_by,omitempty"`
225-
IssueType string `json:"issue_type,omitempty"`
207+
Number int `json:"number"`
208+
Title string `json:"title"`
209+
Body string `json:"body,omitempty"`
210+
State string `json:"state"`
211+
StateReason string `json:"state_reason,omitempty"`
212+
Draft bool `json:"draft,omitempty"`
213+
Locked bool `json:"locked,omitempty"`
214+
HTMLURL string `json:"html_url,omitempty"`
215+
User *MinimalUser `json:"user,omitempty"`
216+
AuthorAssociation string `json:"author_association,omitempty"`
217+
Labels []string `json:"labels,omitempty"`
218+
Assignees []string `json:"assignees,omitempty"`
219+
Milestone string `json:"milestone,omitempty"`
220+
Comments int `json:"comments,omitempty"`
221+
Reactions *MinimalReactions `json:"reactions,omitempty"`
222+
CreatedAt string `json:"created_at,omitempty"`
223+
UpdatedAt string `json:"updated_at,omitempty"`
224+
ClosedAt string `json:"closed_at,omitempty"`
225+
ClosedBy string `json:"closed_by,omitempty"`
226+
IssueType string `json:"issue_type,omitempty"`
227+
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
228+
}
229+
230+
// MinimalIssueFieldValue is the trimmed output type for a custom issue field value.
231+
// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select.
232+
type MinimalIssueFieldValue struct {
233+
Field string `json:"field"`
234+
Value string `json:"value,omitempty"`
235+
Values []string `json:"values,omitempty"`
226236
}
227237

228238
// MinimalIssuesResponse is the trimmed output for a paginated list of issues.
@@ -435,9 +445,43 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue {
435445
m.Labels = append(m.Labels, string(label.Name))
436446
}
437447

448+
for _, fv := range fragment.IssueFieldValues.Nodes {
449+
if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok {
450+
m.FieldValues = append(m.FieldValues, mfv)
451+
}
452+
}
453+
438454
return m
439455
}
440456

457+
// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single
458+
// {field, value} pair. Returns ok=false if the typename is unrecognised.
459+
func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) {
460+
switch fv.TypeName {
461+
case "IssueFieldDateValue":
462+
return MinimalIssueFieldValue{
463+
Field: fv.DateValue.Field.Name(),
464+
Value: string(fv.DateValue.Value),
465+
}, true
466+
case "IssueFieldNumberValue":
467+
return MinimalIssueFieldValue{
468+
Field: fv.NumberValue.Field.Name(),
469+
Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64),
470+
}, true
471+
case "IssueFieldSingleSelectValue":
472+
return MinimalIssueFieldValue{
473+
Field: fv.SingleSelectValue.Field.Name(),
474+
Value: string(fv.SingleSelectValue.Value),
475+
}, true
476+
case "IssueFieldTextValue":
477+
return MinimalIssueFieldValue{
478+
Field: fv.TextValue.Field.Name(),
479+
Value: string(fv.TextValue.Value),
480+
}, true
481+
}
482+
return MinimalIssueFieldValue{}, false
483+
}
484+
441485
func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse {
442486
minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes))
443487
for _, issue := range fragment.Nodes {

0 commit comments

Comments
 (0)