Skip to content

Commit a66606f

Browse files
authored
Add custom field filtering to list_issues
1 parent 0fccfb0 commit a66606f

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
@@ -865,6 +865,7 @@ The following sets of tools are available:
865865
- **Required OAuth Scopes**: `repo`
866866
- `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional)
867867
- `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional)
868+
- `field_filters`: Filter by custom issue field values. Each entry must specify field_name and exactly one typed value field. (object[], optional)
868869
- `labels`: Filter by labels (string[], optional)
869870
- `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional)
870871
- `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
@@ -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

Comments
 (0)