Skip to content

Commit 20d45e2

Browse files
authored
Allow tool to support read:org or repo
1 parent 0fd7142 commit 20d45e2

3 files changed

Lines changed: 136 additions & 79 deletions

File tree

pkg/github/__toolsnaps__/list_issue_fields.snap

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
{
22
"annotations": {
33
"readOnlyHint": true,
4-
"title": "List repository issue fields"
4+
"title": "List issue fields"
55
},
6-
"description": "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names.",
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.",
77
"inputSchema": {
88
"properties": {
99
"owner": {
10-
"description": "The account owner of the repository. The name is not case sensitive.",
10+
"description": "The account owner of the repository or organization. The name is not case sensitive.",
1111
"type": "string"
1212
},
1313
"repo": {
14-
"description": "The name of the repository. The name is not case sensitive.",
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.",
1515
"type": "string"
1616
}
1717
},
1818
"required": [
19-
"owner",
20-
"repo"
19+
"owner"
2120
],
2221
"type": "object"
2322
},

pkg/github/issue_fields.go

Lines changed: 85 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -34,86 +34,98 @@ type IssueSingleSelectFieldOption struct {
3434
Priority *int `json:"priority,omitempty"`
3535
}
3636

37-
// issueFieldsQuery is the GraphQL query for listing issue fields on a repository.
38-
type issueFieldsQuery struct {
37+
// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union.
38+
type issueFieldNode struct {
39+
TypeName githubv4.String `graphql:"__typename"`
40+
// All field types share these fields; any populated fragment gives the same values.
41+
IssueFieldText struct {
42+
ID githubv4.ID
43+
Name githubv4.String
44+
Description githubv4.String
45+
DataType githubv4.String
46+
Visibility githubv4.String
47+
} `graphql:"... on IssueFieldText"`
48+
IssueFieldNumber struct {
49+
ID githubv4.ID
50+
Name githubv4.String
51+
Description githubv4.String
52+
DataType githubv4.String
53+
Visibility githubv4.String
54+
} `graphql:"... on IssueFieldNumber"`
55+
IssueFieldDate struct {
56+
ID githubv4.ID
57+
Name githubv4.String
58+
Description githubv4.String
59+
DataType githubv4.String
60+
Visibility githubv4.String
61+
} `graphql:"... on IssueFieldDate"`
62+
IssueFieldSingleSelect struct {
63+
ID githubv4.ID
64+
Name githubv4.String
65+
Description githubv4.String
66+
DataType githubv4.String
67+
Visibility githubv4.String
68+
Options []struct {
69+
ID githubv4.ID
70+
Name githubv4.String
71+
Description githubv4.String
72+
Color githubv4.String
73+
Priority *int
74+
}
75+
} `graphql:"... on IssueFieldSingleSelect"`
76+
}
77+
78+
// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository.
79+
type issueFieldsRepoQuery struct {
3980
Repository struct {
4081
IssueFields struct {
41-
Nodes []struct {
42-
TypeName githubv4.String `graphql:"__typename"`
43-
// All field types share these fields; any populated fragment gives the same values.
44-
IssueFieldText struct {
45-
ID githubv4.ID
46-
Name githubv4.String
47-
Description githubv4.String
48-
DataType githubv4.String
49-
Visibility githubv4.String
50-
} `graphql:"... on IssueFieldText"`
51-
IssueFieldNumber struct {
52-
ID githubv4.ID
53-
Name githubv4.String
54-
Description githubv4.String
55-
DataType githubv4.String
56-
Visibility githubv4.String
57-
} `graphql:"... on IssueFieldNumber"`
58-
IssueFieldDate struct {
59-
ID githubv4.ID
60-
Name githubv4.String
61-
Description githubv4.String
62-
DataType githubv4.String
63-
Visibility githubv4.String
64-
} `graphql:"... on IssueFieldDate"`
65-
IssueFieldSingleSelect struct {
66-
ID githubv4.ID
67-
Name githubv4.String
68-
Description githubv4.String
69-
DataType githubv4.String
70-
Visibility githubv4.String
71-
Options []struct {
72-
ID githubv4.ID
73-
Name githubv4.String
74-
Description githubv4.String
75-
Color githubv4.String
76-
Priority *int
77-
}
78-
} `graphql:"... on IssueFieldSingleSelect"`
79-
}
82+
Nodes []issueFieldNode
8083
} `graphql:"issueFields(first: 100)"`
8184
} `graphql:"repository(owner: $owner, name: $name)"`
8285
}
8386

84-
// ListIssueFields creates a tool to list issue field definitions for a repository.
87+
// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization.
88+
type issueFieldsOrgQuery struct {
89+
Organization struct {
90+
IssueFields struct {
91+
Nodes []issueFieldNode
92+
} `graphql:"issueFields(first: 100)"`
93+
} `graphql:"organization(login: $login)"`
94+
}
95+
96+
// ListIssueFields creates a tool to list issue field definitions for a repository or organization.
8597
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
8698
return NewTool(
8799
ToolsetMetadataIssues,
88100
mcp.Tool{
89101
Name: "list_issue_fields",
90-
Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names."),
102+
Description: t("TOOL_LIST_ISSUE_FIELDS_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."),
91103
Annotations: &mcp.ToolAnnotations{
92-
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List repository issue fields"),
104+
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
93105
ReadOnlyHint: true,
94106
},
95107
InputSchema: &jsonschema.Schema{
96108
Type: "object",
97109
Properties: map[string]*jsonschema.Schema{
98110
"owner": {
99111
Type: "string",
100-
Description: "The account owner of the repository. The name is not case sensitive.",
112+
Description: "The account owner of the repository or organization. The name is not case sensitive.",
101113
},
102114
"repo": {
103115
Type: "string",
104-
Description: "The name of the repository. The name is not case sensitive.",
116+
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.",
105117
},
106118
},
107-
Required: []string{"owner", "repo"},
119+
Required: []string{"owner"},
108120
},
109121
},
110-
[]scopes.Scope{scopes.Repo},
122+
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
111123
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
112124
owner, err := RequiredParam[string](args, "owner")
113125
if err != nil {
114126
return utils.NewToolResultError(err.Error()), nil, nil
115127
}
116-
repo, err := RequiredParam[string](args, "repo")
128+
repo, err := OptionalParam[string](args, "repo")
117129
if err != nil {
118130
return utils.NewToolResultError(err.Error()), nil, nil
119131
}
@@ -123,21 +135,34 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool
123135
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
124136
}
125137

126-
var query issueFieldsQuery
127-
vars := map[string]any{
128-
"owner": githubv4.String(owner),
129-
"name": githubv4.String(repo),
130-
}
131138
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields")
132-
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
133-
return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil
139+
var nodes []issueFieldNode
140+
if repo != "" {
141+
var query issueFieldsRepoQuery
142+
vars := map[string]any{
143+
"owner": githubv4.String(owner),
144+
"name": githubv4.String(repo),
145+
}
146+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
147+
return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil
148+
}
149+
nodes = query.Repository.IssueFields.Nodes
150+
} else {
151+
var query issueFieldsOrgQuery
152+
vars := map[string]any{
153+
"login": githubv4.String(owner),
154+
}
155+
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
156+
return utils.NewToolResultErrorFromErr("failed to list issue fields", err), nil, nil
157+
}
158+
nodes = query.Organization.IssueFields.Nodes
134159
}
135160

136-
fields := make([]IssueField, 0, len(query.Repository.IssueFields.Nodes))
137-
for _, node := range query.Repository.IssueFields.Nodes {
161+
fields := make([]IssueField, 0, len(nodes))
162+
for _, node := range nodes {
138163
var f IssueField
139-
// Use TypeName to discriminate; shurcooL populates all fragment structs with the
140-
// same shared field values, so any non-SingleSelect struct gives the correct data.
164+
// Use TypeName to discriminate; shurcooL populates all matching fragment structs,
165+
// so any non-SingleSelect struct gives the correct shared field values.
141166
switch string(node.TypeName) {
142167
case "IssueFieldSingleSelect":
143168
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options))

pkg/github/issue_fields_test.go

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,21 +25,29 @@ func Test_ListIssueFields(t *testing.T) {
2525
assert.True(t, tool.Annotations.ReadOnlyHint)
2626
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
2727
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
28-
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "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"})
2931

30-
queryStruct := issueFieldsQuery{}
32+
queryStruct := issueFieldsRepoQuery{}
3133
defaultVars := map[string]any{
3234
"owner": githubv4.String("testowner"),
3335
"name": githubv4.String("testrepo"),
3436
}
37+
orgQueryStruct := issueFieldsOrgQuery{}
38+
defaultOrgVars := map[string]any{
39+
"login": githubv4.String("testowner"),
40+
}
3541

3642
tests := []struct {
37-
name string
38-
requestArgs map[string]any
39-
gqlResponse githubv4mock.GQLResponse
40-
expectError bool
41-
expectedFields []IssueField
42-
expectedErrMsg string
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
4351
}{
4452
{
4553
name: "no fields returns empty list",
@@ -146,20 +154,45 @@ func Test_ListIssueFields(t *testing.T) {
146154
expectedErrMsg: "missing required parameter: owner",
147155
},
148156
{
149-
name: "missing repo parameter",
157+
name: "no repo returns org-level fields",
150158
requestArgs: map[string]any{
151159
"owner": "testowner",
152160
},
153-
gqlResponse: githubv4mock.DataResponse(map[string]any{}),
154-
expectError: true,
155-
expectedErrMsg: "missing required parameter: repo",
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+
},
156181
},
157182
}
158183

159184
for _, tc := range tests {
160185
t.Run(tc.name, func(t *testing.T) {
186+
qs := tc.mockQueryStruct
187+
if qs == nil {
188+
qs = queryStruct
189+
}
190+
vars := tc.mockVars
191+
if vars == nil {
192+
vars = defaultVars
193+
}
161194
mockedHTTPClient := githubv4mock.NewMockedHTTPClient(
162-
githubv4mock.NewQueryMatcher(queryStruct, defaultVars, tc.gqlResponse),
195+
githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse),
163196
)
164197
gqlClient := githubv4.NewClient(mockedHTTPClient)
165198
deps := BaseDeps{GQLClient: gqlClient}

0 commit comments

Comments
 (0)