Skip to content

Commit b57cd61

Browse files
authored
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.
1 parent e2ff518 commit b57cd61

3 files changed

Lines changed: 142 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: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,15 @@ func Test_ListIssues(t *testing.T) {
10631063
"comments": map[string]any{
10641064
"totalCount": 5,
10651065
},
1066+
"issueFieldValues": map[string]any{
1067+
"nodes": []map[string]any{
1068+
{
1069+
"__typename": "IssueFieldSingleSelectValue",
1070+
"field": map[string]any{"name": "priority"},
1071+
"value": "P1",
1072+
},
1073+
},
1074+
},
10661075
},
10671076
{
10681077
"number": 456,
@@ -1081,6 +1090,9 @@ func Test_ListIssues(t *testing.T) {
10811090
"comments": map[string]any{
10821091
"totalCount": 3,
10831092
},
1093+
"issueFieldValues": map[string]any{
1094+
"nodes": []map[string]any{},
1095+
},
10841096
},
10851097
}
10861098

@@ -1101,6 +1113,9 @@ func Test_ListIssues(t *testing.T) {
11011113
"comments": map[string]any{
11021114
"totalCount": 1,
11031115
},
1116+
"issueFieldValues": map[string]any{
1117+
"nodes": []map[string]any{},
1118+
},
11041119
},
11051120
}
11061121

@@ -1275,8 +1290,9 @@ func Test_ListIssues(t *testing.T) {
12751290
}
12761291

12771292
// Define the actual query strings that match the implementation
1278-
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}}"
1279-
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}}"
1293+
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}}}"
1294+
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}}"
1295+
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}}"
12801296

12811297
for _, tc := range tests {
12821298
t.Run(tc.name, func(t *testing.T) {
@@ -1347,6 +1363,14 @@ func Test_ListIssues(t *testing.T) {
13471363
for _, label := range issue.Labels {
13481364
assert.NotEmpty(t, label, "Label should be a non-empty string")
13491365
}
1366+
1367+
// Field values should be flattened to {field, value} pairs. Issue #123 in the mock
1368+
// data has a SingleSelectValue for "priority"; all others have an empty list.
1369+
if issue.Number == 123 {
1370+
assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues)
1371+
} else {
1372+
assert.Empty(t, issue.FieldValues)
1373+
}
13501374
}
13511375
})
13521376
}
@@ -1392,7 +1416,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
13921416
})
13931417
}
13941418

1395-
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}}"
1419+
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}}"
13961420

13971421
vars := map[string]any{
13981422
"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/v82/github"
@@ -171,26 +172,35 @@ type MinimalReactions struct {
171172

172173
// MinimalIssue is the trimmed output type for issue objects to reduce verbosity.
173174
type MinimalIssue struct {
174-
Number int `json:"number"`
175-
Title string `json:"title"`
176-
Body string `json:"body,omitempty"`
177-
State string `json:"state"`
178-
StateReason string `json:"state_reason,omitempty"`
179-
Draft bool `json:"draft,omitempty"`
180-
Locked bool `json:"locked,omitempty"`
181-
HTMLURL string `json:"html_url,omitempty"`
182-
User *MinimalUser `json:"user,omitempty"`
183-
AuthorAssociation string `json:"author_association,omitempty"`
184-
Labels []string `json:"labels,omitempty"`
185-
Assignees []string `json:"assignees,omitempty"`
186-
Milestone string `json:"milestone,omitempty"`
187-
Comments int `json:"comments,omitempty"`
188-
Reactions *MinimalReactions `json:"reactions,omitempty"`
189-
CreatedAt string `json:"created_at,omitempty"`
190-
UpdatedAt string `json:"updated_at,omitempty"`
191-
ClosedAt string `json:"closed_at,omitempty"`
192-
ClosedBy string `json:"closed_by,omitempty"`
193-
IssueType string `json:"issue_type,omitempty"`
175+
Number int `json:"number"`
176+
Title string `json:"title"`
177+
Body string `json:"body,omitempty"`
178+
State string `json:"state"`
179+
StateReason string `json:"state_reason,omitempty"`
180+
Draft bool `json:"draft,omitempty"`
181+
Locked bool `json:"locked,omitempty"`
182+
HTMLURL string `json:"html_url,omitempty"`
183+
User *MinimalUser `json:"user,omitempty"`
184+
AuthorAssociation string `json:"author_association,omitempty"`
185+
Labels []string `json:"labels,omitempty"`
186+
Assignees []string `json:"assignees,omitempty"`
187+
Milestone string `json:"milestone,omitempty"`
188+
Comments int `json:"comments,omitempty"`
189+
Reactions *MinimalReactions `json:"reactions,omitempty"`
190+
CreatedAt string `json:"created_at,omitempty"`
191+
UpdatedAt string `json:"updated_at,omitempty"`
192+
ClosedAt string `json:"closed_at,omitempty"`
193+
ClosedBy string `json:"closed_by,omitempty"`
194+
IssueType string `json:"issue_type,omitempty"`
195+
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
196+
}
197+
198+
// MinimalIssueFieldValue is the trimmed output type for a custom issue field value.
199+
// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select.
200+
type MinimalIssueFieldValue struct {
201+
Field string `json:"field"`
202+
Value string `json:"value,omitempty"`
203+
Values []string `json:"values,omitempty"`
194204
}
195205

196206
// MinimalIssuesResponse is the trimmed output for a paginated list of issues.
@@ -403,9 +413,43 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue {
403413
m.Labels = append(m.Labels, string(label.Name))
404414
}
405415

416+
for _, fv := range fragment.IssueFieldValues.Nodes {
417+
if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok {
418+
m.FieldValues = append(m.FieldValues, mfv)
419+
}
420+
}
421+
406422
return m
407423
}
408424

425+
// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single
426+
// {field, value} pair. Returns ok=false if the typename is unrecognised.
427+
func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) {
428+
switch fv.TypeName {
429+
case "IssueFieldDateValue":
430+
return MinimalIssueFieldValue{
431+
Field: fv.DateValue.Field.Name(),
432+
Value: string(fv.DateValue.Value),
433+
}, true
434+
case "IssueFieldNumberValue":
435+
return MinimalIssueFieldValue{
436+
Field: fv.NumberValue.Field.Name(),
437+
Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64),
438+
}, true
439+
case "IssueFieldSingleSelectValue":
440+
return MinimalIssueFieldValue{
441+
Field: fv.SingleSelectValue.Field.Name(),
442+
Value: string(fv.SingleSelectValue.Value),
443+
}, true
444+
case "IssueFieldTextValue":
445+
return MinimalIssueFieldValue{
446+
Field: fv.TextValue.Field.Name(),
447+
Value: string(fv.TextValue.Value),
448+
}, true
449+
}
450+
return MinimalIssueFieldValue{}, false
451+
}
452+
409453
func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse {
410454
minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes))
411455
for _, issue := range fragment.Nodes {

0 commit comments

Comments
 (0)