Skip to content

Commit c77ac79

Browse files
authored
Add custom field filtering to list_issues
1 parent cc2a957 commit c77ac79

4 files changed

Lines changed: 521 additions & 58 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,7 @@ The following sets of tools are available:
875875
- **Required OAuth Scopes**: `repo`
876876
- `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
877877
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
878+
- `field_filters`: Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field. (object[], optional)
878879
- `labels`: Filter by labels (string[], optional)
879880
- `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional)
880881
- `owner`: Repository owner (string, required)

pkg/github/__toolsnaps__/list_issues.snap

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,38 @@
1818
],
1919
"type": "string"
2020
},
21+
"field_filters": {
22+
"description": "Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field.",
23+
"items": {
24+
"properties": {
25+
"date_value": {
26+
"description": "For date fields, the date to match (YYYY-MM-DD).",
27+
"type": "string"
28+
},
29+
"field_name": {
30+
"description": "Name of the custom field (e.g. \"Priority\").",
31+
"type": "string"
32+
},
33+
"number_value": {
34+
"description": "For number fields, the numeric value to match.",
35+
"type": "number"
36+
},
37+
"single_select_value": {
38+
"description": "For single-select fields, the option name to match (e.g. \"P1\").",
39+
"type": "string"
40+
},
41+
"text_value": {
42+
"description": "For text fields, the text value to match.",
43+
"type": "string"
44+
}
45+
},
46+
"required": [
47+
"field_name"
48+
],
49+
"type": "object"
50+
},
51+
"type": "array"
52+
},
2153
"labels": {
2254
"description": "Filter by labels",
2355
"items": {

pkg/github/issues.go

Lines changed: 137 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
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.
200201
type 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.
208209
type 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.
216217
type 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.
224225
type 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
232243
func (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

Comments
 (0)