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
@@ -1569,6 +1580,36 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
15691580 Type : "string" ,
15701581 Description : "Filter by date (ISO 8601 timestamp)" ,
15711582 },
1583+ "field_filters" : {
1584+ Type : "array" ,
1585+ Description : "Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field." ,
1586+ Items : & jsonschema.Schema {
1587+ Type : "object" ,
1588+ Properties : map [string ]* jsonschema.Schema {
1589+ "field_name" : {
1590+ Type : "string" ,
1591+ Description : "Name of the custom field (e.g. \" Priority\" )." ,
1592+ },
1593+ "single_select_value" : {
1594+ Type : "string" ,
1595+ Description : "For single-select fields, the option name to match (e.g. \" P1\" )." ,
1596+ },
1597+ "text_value" : {
1598+ Type : "string" ,
1599+ Description : "For text fields, the text value to match." ,
1600+ },
1601+ "number_value" : {
1602+ Type : "number" ,
1603+ Description : "For number fields, the numeric value to match." ,
1604+ },
1605+ "date_value" : {
1606+ Type : "string" ,
1607+ Description : "For date fields, the date to match (YYYY-MM-DD)." ,
1608+ },
1609+ },
1610+ Required : []string {"field_name" },
1611+ },
1612+ },
15721613 },
15731614 Required : []string {"owner" , "repo" },
15741615 }
@@ -1664,6 +1705,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16641705 }
16651706 hasLabels := len (labels ) > 0
16661707
1708+ fieldFilters , err := parseFieldFilters (args )
1709+ if err != nil {
1710+ return utils .NewToolResultError (err .Error ()), nil , nil
1711+ }
1712+
16671713 // Get pagination parameters and convert to GraphQL format
16681714 pagination , err := OptionalCursorPaginationParams (args )
16691715 if err != nil {
@@ -1696,12 +1742,13 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
16961742 }
16971743
16981744 vars := map [string ]any {
1699- "owner" : githubv4 .String (owner ),
1700- "repo" : githubv4 .String (repo ),
1701- "states" : states ,
1702- "orderBy" : githubv4 .IssueOrderField (orderBy ),
1703- "direction" : githubv4 .OrderDirection (direction ),
1704- "first" : githubv4 .Int (* paginationParams .First ),
1745+ "owner" : githubv4 .String (owner ),
1746+ "repo" : githubv4 .String (repo ),
1747+ "states" : states ,
1748+ "orderBy" : githubv4 .IssueOrderField (orderBy ),
1749+ "direction" : githubv4 .OrderDirection (direction ),
1750+ "first" : githubv4 .Int (* paginationParams .First ),
1751+ "issueFieldValues" : fieldFilters ,
17051752 }
17061753
17071754 if paginationParams .After != nil {
@@ -1726,7 +1773,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
17261773 }
17271774
17281775 issueQuery := getIssueQueryType (hasLabels , hasSince )
1729- if err := client .Query (ctx , issueQuery , vars ); err != nil {
1776+ // The list_issues query references the issue_fields-gated IssueFieldValueFilter
1777+ // input type unconditionally, so we always opt into the feature via header. This
1778+ // is a no-op once the flag is globally rolled out.
1779+ ctxWithFeatures := ghcontext .WithGraphQLFeatures (ctx , "issue_fields" )
1780+ if err := client .Query (ctxWithFeatures , issueQuery , vars ); err != nil {
17301781 return ghErrors .NewGitHubGraphQLErrorResponse (
17311782 ctx ,
17321783 "failed to list issues" ,
@@ -1752,6 +1803,81 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
17521803 })
17531804}
17541805
1806+ // parseFieldFilters extracts the optional field_filters parameter and converts it to
1807+ // a slice of IssueFieldValueFilter for the GraphQL issueFieldValues variable. Validates that exactly one typed value is set per filter.
1808+ func parseFieldFilters (args map [string ]any ) ([]IssueFieldValueFilter , error ) {
1809+ raw , ok := args ["field_filters" ]
1810+ if ! ok {
1811+ return []IssueFieldValueFilter {}, nil
1812+ }
1813+
1814+ var entries []map [string ]any
1815+ switch v := raw .(type ) {
1816+ case []any :
1817+ for _ , f := range v {
1818+ entry , ok := f .(map [string ]any )
1819+ if ! ok {
1820+ return nil , fmt .Errorf ("each field_filters entry must be an object" )
1821+ }
1822+ entries = append (entries , entry )
1823+ }
1824+ case []map [string ]any :
1825+ entries = v
1826+ default :
1827+ return nil , fmt .Errorf ("field_filters must be an array" )
1828+ }
1829+
1830+ filters := make ([]IssueFieldValueFilter , 0 , len (entries ))
1831+ for _ , entry := range entries {
1832+ fieldName , err := RequiredParam [string ](entry , "field_name" )
1833+ if err != nil {
1834+ return nil , fmt .Errorf ("field_filters entry: %s" , err .Error ())
1835+ }
1836+
1837+ filter := IssueFieldValueFilter {FieldName : githubv4 .String (fieldName )}
1838+ valueCount := 0
1839+
1840+ // Use OptionalParamOK uniformly so type errors propagate and so that
1841+ // number_value: 0 is treated as a set value (not as absent).
1842+ if v , ok , err := OptionalParamOK [string ](entry , "single_select_value" ); err != nil {
1843+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1844+ } else if ok && v != "" {
1845+ filter .SingleSelectOptionValue = githubv4 .NewString (githubv4 .String (v ))
1846+ valueCount ++
1847+ }
1848+ if v , ok , err := OptionalParamOK [string ](entry , "text_value" ); err != nil {
1849+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1850+ } else if ok && v != "" {
1851+ filter .TextValue = githubv4 .NewString (githubv4 .String (v ))
1852+ valueCount ++
1853+ }
1854+ if v , ok , err := OptionalParamOK [string ](entry , "date_value" ); err != nil {
1855+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1856+ } else if ok && v != "" {
1857+ filter .DateValue = githubv4 .NewString (githubv4 .String (v ))
1858+ valueCount ++
1859+ }
1860+ if v , ok , err := OptionalParamOK [float64 ](entry , "number_value" ); err != nil {
1861+ return nil , fmt .Errorf ("field_filters entry %q: %s" , fieldName , err .Error ())
1862+ } else if ok {
1863+ n := githubv4 .Float (v )
1864+ filter .NumberValue = & n
1865+ valueCount ++
1866+ }
1867+
1868+ if valueCount == 0 {
1869+ return nil , fmt .Errorf ("field_filters entry %q: exactly one of single_select_value, text_value, date_value, or number_value is required" , fieldName )
1870+ }
1871+ if valueCount > 1 {
1872+ 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 )
1873+ }
1874+
1875+ filters = append (filters , filter )
1876+ }
1877+
1878+ return filters , nil
1879+ }
1880+
17551881// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
17561882// Returns the parsed time or an error if parsing fails.
17571883// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
0 commit comments