diff --git a/pkg/github/issues.go b/pkg/github/issues.go index e3e1f6b22..8ca9446e3 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -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 @@ -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 diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 49ce2dde9..c6ecdf99e 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -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, @@ -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", + }, + }, + }, }, } @@ -1101,6 +1129,9 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -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) { @@ -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) + } } }) } @@ -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", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a8757c51c..e22fdae51 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,7 @@ package github import ( + "strconv" "time" "github.com/google/go-github/v82/github" @@ -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. @@ -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 + } + return MinimalIssueFieldValue{}, false +} + func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse { minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) for _, issue := range fragment.Nodes {