Skip to content

Commit 5b25f55

Browse files
authored
Add get project fields tool
1 parent 35b0da2 commit 5b25f55

File tree

5 files changed

+273
-3
lines changed

5 files changed

+273
-3
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,7 @@ The following sets of tools are available (all are on by default):
667667
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
668668
- `owner_type`: Owner type (string, required)
669669
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
670-
- `projectNumber`: The project's number. (string, required)
670+
- `projectNumber`: The project's number. (number, required)
671671

672672
- **list_projects** - List projects
673673
- `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required)
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{
2+
"annotations": {
3+
"title": "Get project field",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get Project field for a user or org",
7+
"inputSchema": {
8+
"properties": {
9+
"field_id": {
10+
"description": "The field's id.",
11+
"type": "number"
12+
},
13+
"owner": {
14+
"description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.",
15+
"type": "string"
16+
},
17+
"owner_type": {
18+
"description": "Owner type",
19+
"enum": [
20+
"user",
21+
"org"
22+
],
23+
"type": "string"
24+
},
25+
"per_page": {
26+
"description": "Number of results per page (max 100, default: 30)",
27+
"type": "number"
28+
},
29+
"projectNumber": {
30+
"description": "The project's number.",
31+
"type": "number"
32+
}
33+
},
34+
"required": [
35+
"owner_type",
36+
"owner",
37+
"projectNumber",
38+
"field_id"
39+
],
40+
"type": "object"
41+
},
42+
"name": "get_project_field"
43+
}

pkg/github/__toolsnaps__/list_project_fields.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
},
2525
"projectNumber": {
2626
"description": "The project's number.",
27-
"type": "string"
27+
"type": "number"
2828
}
2929
},
3030
"required": [

pkg/github/projects.go

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
174174
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}),
175175
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
176176
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
177-
mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
177+
mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
178178
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
179179
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
180180
owner, err := RequiredParam[string](req, "owner")
@@ -247,6 +247,76 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
247247
}
248248
}
249249

250+
func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
251+
return mcp.NewTool("get_project_field",
252+
mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")),
253+
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), ReadOnlyHint: ToBoolPtr(true)}),
254+
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
255+
mcp.WithString("owner", mcp.Required(), mcp.Description("If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.")),
256+
mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
257+
mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")),
258+
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
259+
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
260+
owner, err := RequiredParam[string](req, "owner")
261+
if err != nil {
262+
return mcp.NewToolResultError(err.Error()), nil
263+
}
264+
ownerType, err := RequiredParam[string](req, "owner_type")
265+
if err != nil {
266+
return mcp.NewToolResultError(err.Error()), nil
267+
}
268+
projectNumber, err := RequiredParam[int64](req, "projectNumber")
269+
if err != nil {
270+
return mcp.NewToolResultError(err.Error()), nil
271+
}
272+
fieldID, err := RequiredParam[int64](req, "field_id")
273+
if err != nil {
274+
return mcp.NewToolResultError(err.Error()), nil
275+
}
276+
client, err := getClient(ctx)
277+
if err != nil {
278+
return mcp.NewToolResultError(err.Error()), nil
279+
}
280+
281+
var url string
282+
if ownerType == "org" {
283+
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
284+
} else {
285+
url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
286+
}
287+
projectField := projectV2Field{}
288+
289+
httpRequest, err := client.NewRequest("GET", url, nil)
290+
if err != nil {
291+
return nil, fmt.Errorf("failed to create request: %w", err)
292+
}
293+
294+
resp, err := client.Do(ctx, httpRequest, &projectField)
295+
if err != nil {
296+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
297+
"failed to get project field",
298+
resp,
299+
err,
300+
), nil
301+
}
302+
defer func() { _ = resp.Body.Close() }()
303+
304+
if resp.StatusCode != http.StatusOK {
305+
body, err := io.ReadAll(resp.Body)
306+
if err != nil {
307+
return nil, fmt.Errorf("failed to read response body: %w", err)
308+
}
309+
return mcp.NewToolResultError(fmt.Sprintf("failed to get project field: %s", string(body))), nil
310+
}
311+
r, err := json.Marshal(projectField)
312+
if err != nil {
313+
return nil, fmt.Errorf("failed to marshal response: %w", err)
314+
}
315+
316+
return mcp.NewToolResultText(string(r)), nil
317+
}
318+
}
319+
250320
type projectV2Field struct {
251321
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
252322
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.

pkg/github/projects_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,3 +439,160 @@ func Test_ListProjectFields(t *testing.T) {
439439
})
440440
}
441441
}
442+
443+
func Test_GetProjectField(t *testing.T) {
444+
mockClient := gh.NewClient(nil)
445+
tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper)
446+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
447+
448+
assert.Equal(t, "get_project_field", tool.Name)
449+
assert.NotEmpty(t, tool.Description)
450+
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
451+
assert.Contains(t, tool.InputSchema.Properties, "owner")
452+
assert.Contains(t, tool.InputSchema.Properties, "projectNumber")
453+
assert.Contains(t, tool.InputSchema.Properties, "field_id")
454+
assert.Contains(t, tool.InputSchema.Properties, "per_page")
455+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber", "field_id"})
456+
457+
orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"}
458+
userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"}
459+
460+
tests := []struct {
461+
name string
462+
mockedClient *http.Client
463+
requestArgs map[string]any
464+
expectError bool
465+
expectedErrMsg string
466+
expectedID int
467+
}{
468+
{
469+
name: "success organization field",
470+
mockedClient: mock.NewMockedHTTPClient(
471+
mock.WithRequestMatchHandler(
472+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
473+
mockResponse(t, http.StatusOK, orgField),
474+
),
475+
),
476+
requestArgs: map[string]any{
477+
"owner": "octo-org",
478+
"owner_type": "org",
479+
"projectNumber": int64(123),
480+
"field_id": int64(101),
481+
},
482+
expectedID: 101,
483+
},
484+
{
485+
name: "success user field",
486+
mockedClient: mock.NewMockedHTTPClient(
487+
mock.WithRequestMatchHandler(
488+
mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
489+
mockResponse(t, http.StatusOK, userField),
490+
),
491+
),
492+
requestArgs: map[string]any{
493+
"owner": "octocat",
494+
"owner_type": "user",
495+
"projectNumber": int64(456),
496+
"field_id": int64(202),
497+
},
498+
expectedID: 202,
499+
},
500+
{
501+
name: "api error",
502+
mockedClient: mock.NewMockedHTTPClient(
503+
mock.WithRequestMatchHandler(
504+
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
505+
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
506+
),
507+
),
508+
requestArgs: map[string]any{
509+
"owner": "octo-org",
510+
"owner_type": "org",
511+
"projectNumber": int64(789),
512+
"field_id": int64(303),
513+
},
514+
expectError: true,
515+
expectedErrMsg: "failed to get project field",
516+
},
517+
{
518+
name: "missing owner",
519+
mockedClient: mock.NewMockedHTTPClient(),
520+
requestArgs: map[string]any{
521+
"owner_type": "org",
522+
"projectNumber": int64(10),
523+
"field_id": int64(1),
524+
},
525+
expectError: true,
526+
},
527+
{
528+
name: "missing owner_type",
529+
mockedClient: mock.NewMockedHTTPClient(),
530+
requestArgs: map[string]any{
531+
"owner": "octo-org",
532+
"projectNumber": int64(10),
533+
"field_id": int64(1),
534+
},
535+
expectError: true,
536+
},
537+
{
538+
name: "missing projectNumber",
539+
mockedClient: mock.NewMockedHTTPClient(),
540+
requestArgs: map[string]any{
541+
"owner": "octo-org",
542+
"owner_type": "org",
543+
"field_id": int64(1),
544+
},
545+
expectError: true,
546+
},
547+
{
548+
name: "missing field_id",
549+
mockedClient: mock.NewMockedHTTPClient(),
550+
requestArgs: map[string]any{
551+
"owner": "octo-org",
552+
"owner_type": "org",
553+
"projectNumber": int64(10),
554+
},
555+
expectError: true,
556+
},
557+
}
558+
559+
for _, tc := range tests {
560+
t.Run(tc.name, func(t *testing.T) {
561+
client := gh.NewClient(tc.mockedClient)
562+
_, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper)
563+
request := createMCPRequest(tc.requestArgs)
564+
result, err := handler(context.Background(), request)
565+
566+
require.NoError(t, err)
567+
if tc.expectError {
568+
require.True(t, result.IsError)
569+
text := getTextResult(t, result).Text
570+
if tc.expectedErrMsg != "" {
571+
assert.Contains(t, text, tc.expectedErrMsg)
572+
}
573+
if tc.name == "missing owner" {
574+
assert.Contains(t, text, "missing required parameter: owner")
575+
}
576+
if tc.name == "missing owner_type" {
577+
assert.Contains(t, text, "missing required parameter: owner_type")
578+
}
579+
if tc.name == "missing projectNumber" {
580+
assert.Contains(t, text, "missing required parameter: projectNumber")
581+
}
582+
if tc.name == "missing field_id" {
583+
assert.Contains(t, text, "missing required parameter: field_id")
584+
}
585+
return
586+
}
587+
588+
require.False(t, result.IsError)
589+
textContent := getTextResult(t, result)
590+
var field map[string]any
591+
err = json.Unmarshal([]byte(textContent.Text), &field)
592+
require.NoError(t, err)
593+
if tc.expectedID != 0 {
594+
assert.Equal(t, float64(tc.expectedID), field["id"])
595+
}
596+
})
597+
}
598+
}

0 commit comments

Comments
 (0)