Skip to content

Commit 2b7e6fa

Browse files
Merge branch 'main' into kelsey/list-issues-field-filtering
Resolve conflict in pkg/github/issue_fields.go between the squash-merged list_issue_fields tool (#2445) on main and this branch's earlier copy: - Keep main's first: 100 limit and the issue_fields + repo_issue_fields GraphQL feature flags. - Retain this branch's fetchIssueFields / issueFieldsFromNodes helpers, which are used by list_issues for custom field filtering, and rewire ListIssueFields through them to avoid duplication.
2 parents d99fa52 + 8f6050a commit 2b7e6fa

5 files changed

Lines changed: 334 additions & 4 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -870,6 +870,12 @@ The following sets of tools are available:
870870
- `title`: Issue title (string, optional)
871871
- `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
872872

873+
- **list_issue_fields** - List issue fields
874+
- **Required OAuth Scopes**: `repo`, `read:org`
875+
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
876+
- `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required)
877+
- `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional)
878+
873879
- **list_issue_types** - List available issue types
874880
- **Required OAuth Scopes**: `read:org`
875881
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org`
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"annotations": {
3+
"readOnlyHint": true,
4+
"title": "List issue fields"
5+
},
6+
"description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "The account owner of the repository or organization. The name is not case sensitive.",
11+
"type": "string"
12+
},
13+
"repo": {
14+
"description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.",
15+
"type": "string"
16+
}
17+
},
18+
"required": [
19+
"owner"
20+
],
21+
"type": "object"
22+
},
23+
"name": "list_issue_fields"
24+
}

pkg/github/issue_fields.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,11 @@ type issueFieldNode struct {
7777
}
7878

7979
// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository.
80-
// The monolith enforces ORGANIZATION_ISSUE_FIELDS_LIMIT = 25 fields per organization
8180
type issueFieldsRepoQuery struct {
8281
Repository struct {
8382
IssueFields struct {
8483
Nodes []issueFieldNode
85-
} `graphql:"issueFields(first: 25)"`
84+
} `graphql:"issueFields(first: 100)"`
8685
} `graphql:"repository(owner: $owner, name: $name)"`
8786
}
8887

@@ -91,7 +90,7 @@ type issueFieldsOrgQuery struct {
9190
Organization struct {
9291
IssueFields struct {
9392
Nodes []issueFieldNode
94-
} `graphql:"issueFields(first: 25)"`
93+
} `graphql:"issueFields(first: 100)"`
9594
} `graphql:"organization(login: $login)"`
9695
}
9796

@@ -155,7 +154,7 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool
155154
// If repo is provided, fields are scoped to that repository (inherited from its
156155
// organization); otherwise fields are returned directly from the organization.
157156
func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) {
158-
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields")
157+
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
159158
if repo != "" {
160159
var query issueFieldsRepoQuery
161160
vars := map[string]any{

pkg/github/issue_fields_test.go

Lines changed: 300 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/github/github-mcp-server/internal/githubv4mock"
9+
"github.com/github/github-mcp-server/internal/toolsnaps"
10+
"github.com/github/github-mcp-server/pkg/translations"
11+
"github.com/google/jsonschema-go/jsonschema"
12+
"github.com/shurcooL/githubv4"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
func Test_ListIssueFields(t *testing.T) {
18+
// Verify tool definition
19+
serverTool := ListIssueFields(translations.NullTranslationHelper)
20+
tool := serverTool.Tool
21+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
22+
23+
assert.Equal(t, "list_issue_fields", tool.Name)
24+
assert.NotEmpty(t, tool.Description)
25+
assert.True(t, tool.Annotations.ReadOnlyHint)
26+
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
27+
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
28+
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"})
29+
assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"})
30+
assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"})
31+
32+
queryStruct := issueFieldsRepoQuery{}
33+
defaultVars := map[string]any{
34+
"owner": githubv4.String("testowner"),
35+
"name": githubv4.String("testrepo"),
36+
}
37+
orgQueryStruct := issueFieldsOrgQuery{}
38+
defaultOrgVars := map[string]any{
39+
"login": githubv4.String("testowner"),
40+
}
41+
42+
tests := []struct {
43+
name string
44+
requestArgs map[string]any
45+
mockQueryStruct any
46+
mockVars map[string]any
47+
gqlResponse githubv4mock.GQLResponse
48+
expectError bool
49+
expectedFields []IssueField
50+
expectedErrMsg string
51+
}{
52+
{
53+
name: "no fields returns empty list",
54+
requestArgs: map[string]any{
55+
"owner": "testowner",
56+
"repo": "testrepo",
57+
},
58+
gqlResponse: githubv4mock.DataResponse(map[string]any{
59+
"repository": map[string]any{
60+
"issueFields": map[string]any{
61+
"nodes": []any{},
62+
},
63+
},
64+
}),
65+
expectedFields: []IssueField{},
66+
},
67+
{
68+
name: "text field returned",
69+
requestArgs: map[string]any{
70+
"owner": "testowner",
71+
"repo": "testrepo",
72+
},
73+
gqlResponse: githubv4mock.DataResponse(map[string]any{
74+
"repository": map[string]any{
75+
"issueFields": map[string]any{
76+
"nodes": []any{
77+
map[string]any{
78+
"__typename": "IssueFieldText",
79+
"id": "IFT_1",
80+
"name": "DRI",
81+
"description": "Directly responsible individual",
82+
"dataType": "TEXT",
83+
"visibility": "ORG_ONLY",
84+
},
85+
},
86+
},
87+
},
88+
}),
89+
expectedFields: []IssueField{
90+
{
91+
ID: "IFT_1",
92+
Name: "DRI",
93+
Description: "Directly responsible individual",
94+
DataType: "TEXT",
95+
Visibility: "ORG_ONLY",
96+
},
97+
},
98+
},
99+
{
100+
name: "single_select field with options returned",
101+
requestArgs: map[string]any{
102+
"owner": "testowner",
103+
"repo": "testrepo",
104+
},
105+
gqlResponse: githubv4mock.DataResponse(map[string]any{
106+
"repository": map[string]any{
107+
"issueFields": map[string]any{
108+
"nodes": []any{
109+
map[string]any{
110+
"__typename": "IssueFieldSingleSelect",
111+
"id": "IFSS_1",
112+
"name": "Priority",
113+
"description": "Level of importance",
114+
"dataType": "SINGLE_SELECT",
115+
"visibility": "ALL",
116+
"options": []any{
117+
map[string]any{
118+
"id": "OPT_1",
119+
"name": "High",
120+
"color": "red",
121+
},
122+
map[string]any{
123+
"id": "OPT_2",
124+
"name": "Low",
125+
"color": "blue",
126+
},
127+
},
128+
},
129+
},
130+
},
131+
},
132+
}),
133+
expectedFields: []IssueField{
134+
{
135+
ID: "IFSS_1",
136+
Name: "Priority",
137+
Description: "Level of importance",
138+
DataType: "SINGLE_SELECT",
139+
Visibility: "ALL",
140+
Options: []IssueSingleSelectFieldOption{
141+
{ID: "OPT_1", Name: "High", Color: "red"},
142+
{ID: "OPT_2", Name: "Low", Color: "blue"},
143+
},
144+
},
145+
},
146+
},
147+
{
148+
name: "missing owner parameter",
149+
requestArgs: map[string]any{
150+
"repo": "testrepo",
151+
},
152+
gqlResponse: githubv4mock.DataResponse(map[string]any{}),
153+
expectError: true,
154+
expectedErrMsg: "missing required parameter: owner",
155+
},
156+
{
157+
name: "no repo returns org-level fields",
158+
requestArgs: map[string]any{
159+
"owner": "testowner",
160+
},
161+
mockQueryStruct: orgQueryStruct,
162+
mockVars: defaultOrgVars,
163+
gqlResponse: githubv4mock.DataResponse(map[string]any{
164+
"organization": map[string]any{
165+
"issueFields": map[string]any{
166+
"nodes": []any{
167+
map[string]any{
168+
"__typename": "IssueFieldText",
169+
"id": "IFT_1",
170+
"name": "DRI",
171+
"dataType": "TEXT",
172+
"visibility": "ORG_ONLY",
173+
},
174+
},
175+
},
176+
},
177+
}),
178+
expectedFields: []IssueField{
179+
{ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"},
180+
},
181+
},
182+
{
183+
name: "number field returned",
184+
requestArgs: map[string]any{
185+
"owner": "testowner",
186+
"repo": "testrepo",
187+
},
188+
gqlResponse: githubv4mock.DataResponse(map[string]any{
189+
"repository": map[string]any{
190+
"issueFields": map[string]any{
191+
"nodes": []any{
192+
map[string]any{
193+
"__typename": "IssueFieldNumber",
194+
"id": "IFN_1",
195+
"name": "Engineering Staffing",
196+
"dataType": "NUMBER",
197+
"visibility": "ORG_ONLY",
198+
},
199+
},
200+
},
201+
},
202+
}),
203+
expectedFields: []IssueField{
204+
{ID: "IFN_1", Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"},
205+
},
206+
},
207+
{
208+
name: "date field returned",
209+
requestArgs: map[string]any{
210+
"owner": "testowner",
211+
"repo": "testrepo",
212+
},
213+
gqlResponse: githubv4mock.DataResponse(map[string]any{
214+
"repository": map[string]any{
215+
"issueFields": map[string]any{
216+
"nodes": []any{
217+
map[string]any{
218+
"__typename": "IssueFieldDate",
219+
"id": "IFD_1",
220+
"name": "Target Date",
221+
"dataType": "DATE",
222+
"visibility": "ORG_ONLY",
223+
},
224+
},
225+
},
226+
},
227+
}),
228+
expectedFields: []IssueField{
229+
{ID: "IFD_1", Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"},
230+
},
231+
},
232+
{
233+
name: "graphql error returns failure",
234+
requestArgs: map[string]any{
235+
"owner": "testowner",
236+
"repo": "testrepo",
237+
},
238+
gqlResponse: githubv4mock.ErrorResponse("boom"),
239+
expectError: true,
240+
expectedErrMsg: "failed to list issue fields",
241+
},
242+
}
243+
244+
for _, tc := range tests {
245+
t.Run(tc.name, func(t *testing.T) {
246+
qs := tc.mockQueryStruct
247+
if qs == nil {
248+
qs = queryStruct
249+
}
250+
vars := tc.mockVars
251+
if vars == nil {
252+
vars = defaultVars
253+
}
254+
mockedHTTPClient := githubv4mock.NewMockedHTTPClient(
255+
githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse),
256+
)
257+
gqlClient := githubv4.NewClient(mockedHTTPClient)
258+
deps := BaseDeps{GQLClient: gqlClient}
259+
handler := serverTool.Handler(deps)
260+
261+
request := createMCPRequest(tc.requestArgs)
262+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
263+
264+
if tc.expectError {
265+
if err != nil {
266+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
267+
return
268+
}
269+
require.NotNil(t, result)
270+
require.True(t, result.IsError)
271+
errorContent := getErrorResult(t, result)
272+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
273+
return
274+
}
275+
276+
require.NoError(t, err)
277+
require.NotNil(t, result)
278+
require.False(t, result.IsError)
279+
textContent := getTextResult(t, result)
280+
281+
var returnedFields []IssueField
282+
err = json.Unmarshal([]byte(textContent.Text), &returnedFields)
283+
require.NoError(t, err)
284+
require.Equal(t, len(tc.expectedFields), len(returnedFields))
285+
for i, expected := range tc.expectedFields {
286+
assert.Equal(t, expected.ID, returnedFields[i].ID)
287+
assert.Equal(t, expected.Name, returnedFields[i].Name)
288+
assert.Equal(t, expected.DataType, returnedFields[i].DataType)
289+
assert.Equal(t, expected.Visibility, returnedFields[i].Visibility)
290+
if expected.Options != nil {
291+
require.Equal(t, len(expected.Options), len(returnedFields[i].Options))
292+
for j, opt := range expected.Options {
293+
assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name)
294+
assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color)
295+
}
296+
}
297+
}
298+
})
299+
}
300+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
205205
SearchIssues(t),
206206
ListIssues(t),
207207
ListIssueTypes(t),
208+
ListIssueFields(t),
208209
IssueWrite(t),
209210
AddIssueComment(t),
210211
SubIssueWrite(t),

0 commit comments

Comments
 (0)