99 "strings"
1010 "time"
1111
12+ ghcontext "github.com/github/github-mcp-server/pkg/context"
1213 ghErrors "github.com/github/github-mcp-server/pkg/errors"
1314 "github.com/github/github-mcp-server/pkg/ifc"
1415 "github.com/github/github-mcp-server/pkg/inventory"
@@ -199,35 +200,45 @@ type IssueQueryFragment struct {
199200// ListIssuesQuery is the root query structure for fetching issues with optional label filtering.
200201type ListIssuesQuery struct {
201202 Repository struct {
202- Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"`
203+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues} )"`
203204 IsPrivate githubv4.Boolean
204205 } `graphql:"repository(owner: $owner, name: $repo)"`
205206}
206207
207208// ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering.
208209type ListIssuesQueryTypeWithLabels struct {
209210 Repository struct {
210- Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"`
211+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues} )"`
211212 IsPrivate githubv4.Boolean
212213 } `graphql:"repository(owner: $owner, name: $repo)"`
213214}
214215
215216// ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering.
216217type ListIssuesQueryWithSince struct {
217218 Repository struct {
218- Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"`
219+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues })"`
219220 IsPrivate githubv4.Boolean
220221 } `graphql:"repository(owner: $owner, name: $repo)"`
221222}
222223
223224// ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering.
224225type ListIssuesQueryTypeWithLabelsWithSince struct {
225226 Repository struct {
226- Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"`
227+ Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues })"`
227228 IsPrivate githubv4.Boolean
228229 } `graphql:"repository(owner: $owner, name: $repo)"`
229230}
230231
232+ // IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value
233+ // field should be set per filter (the monolith resolver rejects multiple).
234+ type IssueFieldValueFilter struct {
235+ FieldName githubv4.String `json:"fieldName"`
236+ TextValue * githubv4.String `json:"textValue,omitempty"`
237+ DateValue * githubv4.String `json:"dateValue,omitempty"`
238+ NumberValue * githubv4.Float `json:"numberValue,omitempty"`
239+ SingleSelectOptionValue * githubv4.String `json:"singleSelectOptionValue,omitempty"`
240+ }
241+
231242// Implement the interface for all query types
232243func (q * ListIssuesQueryTypeWithLabels ) GetIssueFragment () IssueQueryFragment {
233244 return q .Repository .Issues
@@ -1469,6 +1480,36 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
14691480 Type : "string" ,
14701481 Description : "Filter by date (ISO 8601 timestamp)" ,
14711482 },
1483+ "field_filters" : {
1484+ Type : "array" ,
1485+ Description : "Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field." ,
1486+ Items : & jsonschema.Schema {
1487+ Type : "object" ,
1488+ Properties : map [string ]* jsonschema.Schema {
1489+ "field_name" : {
1490+ Type : "string" ,
1491+ Description : "Name of the custom field (e.g. \" Priority\" )." ,
1492+ },
1493+ "single_select_value" : {
1494+ Type : "string" ,
1495+ Description : "For single-select fields, the option name to match (e.g. \" P1\" )." ,
1496+ },
1497+ "text_value" : {
1498+ Type : "string" ,
1499+ Description : "For text fields, the text value to match." ,
1500+ },
1501+ "number_value" : {
1502+ Type : "number" ,
1503+ Description : "For number fields, the numeric value to match." ,
1504+ },
1505+ "date_value" : {
1506+ Type : "string" ,
1507+ Description : "For date fields, the date to match (YYYY-MM-DD)." ,
1508+ },
1509+ },
1510+ Required : []string {"field_name" },
1511+ },
1512+ },
14721513 },
14731514 Required : []string {"owner" , "repo" },
14741515 }
@@ -1564,6 +1605,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15641605 }
15651606 hasLabels := len (labels ) > 0
15661607
1608+ fieldFilters , err := parseFieldFilters (args )
1609+ if err != nil {
1610+ return utils .NewToolResultError (err .Error ()), nil , nil
1611+ }
1612+
15671613 // Get pagination parameters and convert to GraphQL format
15681614 pagination , err := OptionalCursorPaginationParams (args )
15691615 if err != nil {
@@ -1596,12 +1642,13 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15961642 }
15971643
15981644 vars := map [string ]any {
1599- "owner" : githubv4 .String (owner ),
1600- "repo" : githubv4 .String (repo ),
1601- "states" : states ,
1602- "orderBy" : githubv4 .IssueOrderField (orderBy ),
1603- "direction" : githubv4 .OrderDirection (direction ),
1604- "first" : githubv4 .Int (* paginationParams .First ),
1645+ "owner" : githubv4 .String (owner ),
1646+ "repo" : githubv4 .String (repo ),
1647+ "states" : states ,
1648+ "orderBy" : githubv4 .IssueOrderField (orderBy ),
1649+ "direction" : githubv4 .OrderDirection (direction ),
1650+ "first" : githubv4 .Int (* paginationParams .First ),
1651+ "issueFieldValues" : fieldFilters ,
16051652 }
16061653
16071654 if paginationParams .After != nil {
@@ -1626,7 +1673,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16261673 }
16271674
16281675 issueQuery := getIssueQueryType (hasLabels , hasSince )
1629- if err := client .Query (ctx , issueQuery , vars ); err != nil {
1676+ // The list_issues query references the issue_fields-gated IssueFieldValueFilter
1677+ // input type unconditionally, so we always opt into the feature via header. This
1678+ // is a no-op once the flag is globally rolled out.
1679+ ctxWithFeatures := ghcontext .WithGraphQLFeatures (ctx , "issue_fields" )
1680+ if err := client .Query (ctxWithFeatures , issueQuery , vars ); err != nil {
16301681 return ghErrors .NewGitHubGraphQLErrorResponse (
16311682 ctx ,
16321683 "failed to list issues" ,
@@ -1667,6 +1718,81 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16671718 })
16681719}
16691720
1721+ // parseFieldFilters extracts the optional field_filters parameter and converts it to
1722+ // a slice of IssueFieldValueFilter for the GraphQL issueFieldValues variable. Validates that exactly one typed value is set per filter.
1723+ func parseFieldFilters (args map [string ]any ) ([]IssueFieldValueFilter , error ) {
1724+ raw , ok := args ["field_filters" ]
1725+ if ! ok {
1726+ return []IssueFieldValueFilter {}, nil
1727+ }
1728+
1729+ var entries []map [string ]any
1730+ switch v := raw .(type ) {
1731+ case []any :
1732+ for _ , f := range v {
1733+ entry , ok := f .(map [string ]any )
1734+ if ! ok {
1735+ return nil , fmt .Errorf ("each field_filters entry must be an object" )
1736+ }
1737+ entries = append (entries , entry )
1738+ }
1739+ case []map [string ]any :
1740+ entries = v
1741+ default :
1742+ return nil , fmt .Errorf ("field_filters must be an array" )
1743+ }
1744+
1745+ filters := make ([]IssueFieldValueFilter , 0 , len (entries ))
1746+ for _ , entry := range entries {
1747+ fieldName , err := RequiredParam [string ](entry , "field_name" )
1748+ if err != nil {
1749+ return nil , fmt .Errorf ("field_filters entry: %s" , err .Error ())
1750+ }
1751+
1752+ filter := IssueFieldValueFilter {FieldName : githubv4 .String (fieldName )}
1753+ valueCount := 0
1754+
1755+ // Use OptionalParamOK uniformly so type errors propagate and so that
1756+ // number_value: 0 is treated as a set value (not as absent).
1757+ if v , ok , err := OptionalParamOK [string ](entry , "single_select_value" ); err != nil {
1758+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1759+ } else if ok && v != "" {
1760+ filter .SingleSelectOptionValue = githubv4 .NewString (githubv4 .String (v ))
1761+ valueCount ++
1762+ }
1763+ if v , ok , err := OptionalParamOK [string ](entry , "text_value" ); err != nil {
1764+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1765+ } else if ok && v != "" {
1766+ filter .TextValue = githubv4 .NewString (githubv4 .String (v ))
1767+ valueCount ++
1768+ }
1769+ if v , ok , err := OptionalParamOK [string ](entry , "date_value" ); err != nil {
1770+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1771+ } else if ok && v != "" {
1772+ filter .DateValue = githubv4 .NewString (githubv4 .String (v ))
1773+ valueCount ++
1774+ }
1775+ if v , ok , err := OptionalParamOK [float64 ](entry , "number_value" ); err != nil {
1776+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1777+ } else if ok {
1778+ n := githubv4 .Float (v )
1779+ filter .NumberValue = & n
1780+ valueCount ++
1781+ }
1782+
1783+ if valueCount == 0 {
1784+ return nil , fmt .Errorf ("field_filters entry %q: exactly one of single_select_value, text_value, date_value, or number_value is required" , fieldName )
1785+ }
1786+ if valueCount > 1 {
1787+ return nil , fmt .Errorf ("field_filters entry %q: only one of single_select_value, text_value, date_value, or number_value can be set" , fieldName )
1788+ }
1789+
1790+ filters = append (filters , filter )
1791+ }
1792+
1793+ return filters , nil
1794+ }
1795+
16701796// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
16711797// Returns the parsed time or an error if parsing fails.
16721798// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
0 commit comments