Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,54 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason {
}
}

// IssueFieldRef resolves the name of an issue field across its concrete types.
// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText,
// so we have to ask for `name` on each member.
type IssueFieldRef struct {
Date struct{ Name githubv4.String } `graphql:"... on IssueFieldDate"`
Number struct{ Name githubv4.String } `graphql:"... on IssueFieldNumber"`
SingleSelect struct{ Name githubv4.String } `graphql:"... on IssueFieldSingleSelect"`
Text struct{ Name githubv4.String } `graphql:"... on IssueFieldText"`
}

// Name returns the populated name from whichever IssueFields union variant the field resolved to.
func (r IssueFieldRef) Name() string {
switch {
case r.Date.Name != "":
return string(r.Date.Name)
case r.Number.Name != "":
return string(r.Number.Name)
case r.SingleSelect.Name != "":
return string(r.SingleSelect.Name)
case r.Text.Name != "":
return string(r.Text.Name)
}
return ""
}

// IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union
// of 4 concrete value types; each carries its own value scalar and a reference to its parent field.
// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode.
type IssueFieldValueFragment struct {
TypeName string `graphql:"__typename"`
DateValue struct {
Field IssueFieldRef
Value githubv4.String
} `graphql:"... on IssueFieldDateValue"`
NumberValue struct {
Field IssueFieldRef
Value githubv4.Float `graphql:"valueNumber: value"`
} `graphql:"... on IssueFieldNumberValue"`
SingleSelectValue struct {
Field IssueFieldRef
Value githubv4.String
} `graphql:"... on IssueFieldSingleSelectValue"`
TextValue struct {
Field IssueFieldRef
Value githubv4.String
} `graphql:"... on IssueFieldTextValue"`
}

// IssueFragment represents a fragment of an issue node in the GraphQL API.
type IssueFragment struct {
Number githubv4.Int
Expand All @@ -126,6 +174,9 @@ type IssueFragment struct {
Comments struct {
TotalCount githubv4.Int
} `graphql:"comments"`
IssueFieldValues struct {
Nodes []IssueFieldValueFragment
} `graphql:"issueFieldValues(first: 25)"`
}

// Common interface for all issue query types
Expand Down
54 changes: 51 additions & 3 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1063,6 +1063,15 @@ func Test_ListIssues(t *testing.T) {
"comments": map[string]any{
"totalCount": 5,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldSingleSelectValue",
"field": map[string]any{"name": "priority"},
"value": "P1",
},
},
},
},
{
"number": 456,
Expand All @@ -1081,6 +1090,25 @@ func Test_ListIssues(t *testing.T) {
"comments": map[string]any{
"totalCount": 3,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{
{
"__typename": "IssueFieldDateValue",
"field": map[string]any{"name": "due"},
"value": "2026-06-01",
},
{
"__typename": "IssueFieldNumberValue",
"field": map[string]any{"name": "estimate"},
"valueNumber": 2.5,
},
{
"__typename": "IssueFieldTextValue",
"field": map[string]any{"name": "notes"},
"value": "needs triage",
},
},
},
},
}

Expand All @@ -1101,6 +1129,9 @@ func Test_ListIssues(t *testing.T) {
"comments": map[string]any{
"totalCount": 1,
},
"issueFieldValues": map[string]any{
"nodes": []map[string]any{},
},
},
}

Expand Down Expand Up @@ -1275,8 +1306,9 @@ func Test_ListIssues(t *testing.T) {
}

// Define the actual query strings that match the implementation
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}}"
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}}"
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}}}"
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}}"
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}}"

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
Expand Down Expand Up @@ -1347,6 +1379,22 @@ func Test_ListIssues(t *testing.T) {
for _, label := range issue.Labels {
assert.NotEmpty(t, label, "Label should be a non-empty string")
}

// 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.
switch issue.Number {
case 123:
assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues)
case 456:
assert.Equal(t, []MinimalIssueFieldValue{
{Field: "due", Value: "2026-06-01"},
{Field: "estimate", Value: "2.5"},
{Field: "notes", Value: "needs triage"},
}, issue.FieldValues)
default:
assert.Empty(t, issue.FieldValues)
}
}
})
}
Expand Down Expand Up @@ -1392,7 +1440,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) {
})
}

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}}"
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}}"

vars := map[string]any{
"owner": "octocat",
Expand Down
84 changes: 64 additions & 20 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package github

import (
"strconv"
"time"

"github.com/google/go-github/v82/github"
Expand Down Expand Up @@ -171,26 +172,35 @@ type MinimalReactions struct {

// MinimalIssue is the trimmed output type for issue objects to reduce verbosity.
type MinimalIssue struct {
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
State string `json:"state"`
StateReason string `json:"state_reason,omitempty"`
Draft bool `json:"draft,omitempty"`
Locked bool `json:"locked,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
User *MinimalUser `json:"user,omitempty"`
AuthorAssociation string `json:"author_association,omitempty"`
Labels []string `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Milestone string `json:"milestone,omitempty"`
Comments int `json:"comments,omitempty"`
Reactions *MinimalReactions `json:"reactions,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ClosedAt string `json:"closed_at,omitempty"`
ClosedBy string `json:"closed_by,omitempty"`
IssueType string `json:"issue_type,omitempty"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
State string `json:"state"`
StateReason string `json:"state_reason,omitempty"`
Draft bool `json:"draft,omitempty"`
Locked bool `json:"locked,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
User *MinimalUser `json:"user,omitempty"`
AuthorAssociation string `json:"author_association,omitempty"`
Labels []string `json:"labels,omitempty"`
Assignees []string `json:"assignees,omitempty"`
Milestone string `json:"milestone,omitempty"`
Comments int `json:"comments,omitempty"`
Reactions *MinimalReactions `json:"reactions,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ClosedAt string `json:"closed_at,omitempty"`
ClosedBy string `json:"closed_by,omitempty"`
IssueType string `json:"issue_type,omitempty"`
FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"`
}

// MinimalIssueFieldValue is the trimmed output type for a custom issue field value.
// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select.
type MinimalIssueFieldValue struct {
Field string `json:"field"`
Value string `json:"value,omitempty"`
Values []string `json:"values,omitempty"`
}

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

for _, fv := range fragment.IssueFieldValues.Nodes {
if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok {
m.FieldValues = append(m.FieldValues, mfv)
}
}

return m
}

// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single
// {field, value} pair. Returns ok=false if the typename is unrecognised.
func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) {
switch fv.TypeName {
case "IssueFieldDateValue":
return MinimalIssueFieldValue{
Field: fv.DateValue.Field.Name(),
Value: string(fv.DateValue.Value),
}, true
case "IssueFieldNumberValue":
return MinimalIssueFieldValue{
Field: fv.NumberValue.Field.Name(),
Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64),
}, true
case "IssueFieldSingleSelectValue":
return MinimalIssueFieldValue{
Field: fv.SingleSelectValue.Field.Name(),
Value: string(fv.SingleSelectValue.Value),
}, true
case "IssueFieldTextValue":
return MinimalIssueFieldValue{
Field: fv.TextValue.Field.Name(),
Value: string(fv.TextValue.Value),
}, true
Comment thread
kelsey-myers marked this conversation as resolved.
}
return MinimalIssueFieldValue{}, false
}

func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse {
minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes))
for _, issue := range fragment.Nodes {
Expand Down
Loading