Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -667,7 +667,7 @@ The following sets of tools are available (all are on by default):
- `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)
- `owner_type`: Owner type (string, required)
- `per_page`: Number of results per page (max 100, default: 30) (number, optional)
- `projectNumber`: The project's number. (string, required)
- `projectNumber`: The project's number. (number, required)

- **list_projects** - List projects
- `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)
Expand Down
43 changes: 43 additions & 0 deletions pkg/github/__toolsnaps__/get_project_field.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"annotations": {
"title": "Get project field",
"readOnlyHint": true
},
"description": "Get Project field for a user or org",
"inputSchema": {
"properties": {
"field_id": {
"description": "The field's id.",
"type": "number"
},
"owner": {
"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.",
"type": "string"
},
"owner_type": {
"description": "Owner type",
"enum": [
"user",
"org"
],
"type": "string"
},
"per_page": {
"description": "Number of results per page (max 100, default: 30)",
"type": "number"
},
"projectNumber": {
"description": "The project's number.",
"type": "number"
}
},
"required": [
"owner_type",
"owner",
"projectNumber",
"field_id"
],
"type": "object"
},
"name": "get_project_field"
}
2 changes: 1 addition & 1 deletion pkg/github/__toolsnaps__/list_project_fields.snap
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"projectNumber": {
"description": "The project's number.",
"type": "string"
"type": "number"
}
},
"required": [
Expand Down
72 changes: 71 additions & 1 deletion pkg/github/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), ReadOnlyHint: ToBoolPtr(true)}),
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
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.")),
mcp.WithString("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](req, "owner")
Expand Down Expand Up @@ -247,6 +247,76 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu
}
}

func GetProjectField(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_project_field",
mcp.WithDescription(t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), ReadOnlyHint: ToBoolPtr(true)}),
mcp.WithString("owner_type", mcp.Required(), mcp.Description("Owner type"), mcp.Enum("user", "org")),
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.")),
mcp.WithNumber("projectNumber", mcp.Required(), mcp.Description("The project's number.")),
mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")),
mcp.WithNumber("per_page", mcp.Description("Number of results per page (max 100, default: 30)")),
Comment thread
JoannaaKL marked this conversation as resolved.
Outdated
Comment thread
JoannaaKL marked this conversation as resolved.
Outdated
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](req, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
ownerType, err := RequiredParam[string](req, "owner_type")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
projectNumber, err := RequiredParam[int64](req, "projectNumber")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
fieldID, err := RequiredParam[int64](req, "field_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
client, err := getClient(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}

var url string
if ownerType == "org" {
url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
} else {
url = fmt.Sprintf("users/%s/projectsV2/%d/fields/%d", owner, projectNumber, fieldID)
}
projectField := projectV2Field{}

httpRequest, err := client.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}

resp, err := client.Do(ctx, httpRequest, &projectField)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project field",
resp,
err,
), nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get project field: %s", string(body))), nil
}
r, err := json.Marshal(projectField)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}

return mcp.NewToolResultText(string(r)), nil
}
}

type projectV2Field struct {
ID *int64 `json:"id,omitempty"` // The unique identifier for this field.
NodeID string `json:"node_id,omitempty"` // The GraphQL node ID for this field.
Expand Down
157 changes: 157 additions & 0 deletions pkg/github/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -439,3 +439,160 @@ func Test_ListProjectFields(t *testing.T) {
})
}
}

func Test_GetProjectField(t *testing.T) {
mockClient := gh.NewClient(nil)
tool, _ := GetProjectField(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))

assert.Equal(t, "get_project_field", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner_type")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "projectNumber")
assert.Contains(t, tool.InputSchema.Properties, "field_id")
assert.Contains(t, tool.InputSchema.Properties, "per_page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber", "field_id"})

orgField := map[string]any{"id": 101, "name": "Status", "dataType": "single_select"}
userField := map[string]any{"id": 202, "name": "Priority", "dataType": "single_select"}

tests := []struct {
name string
mockedClient *http.Client
requestArgs map[string]any
expectError bool
expectedErrMsg string
expectedID int
}{
{
name: "success organization field",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
mockResponse(t, http.StatusOK, orgField),
),
),
requestArgs: map[string]any{
"owner": "octo-org",
"owner_type": "org",
"projectNumber": int64(123),
"field_id": int64(101),
},
expectedID: 101,
},
{
name: "success user field",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
mockResponse(t, http.StatusOK, userField),
),
),
requestArgs: map[string]any{
"owner": "octocat",
"owner_type": "user",
"projectNumber": int64(456),
"field_id": int64(202),
},
expectedID: 202,
},
{
name: "api error",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet},
mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}),
),
),
requestArgs: map[string]any{
"owner": "octo-org",
"owner_type": "org",
"projectNumber": int64(789),
"field_id": int64(303),
},
expectError: true,
expectedErrMsg: "failed to get project field",
},
{
name: "missing owner",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"owner_type": "org",
"projectNumber": int64(10),
"field_id": int64(1),
},
expectError: true,
},
{
name: "missing owner_type",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"owner": "octo-org",
"projectNumber": int64(10),
"field_id": int64(1),
},
expectError: true,
},
{
name: "missing projectNumber",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"owner": "octo-org",
"owner_type": "org",
"field_id": int64(1),
},
expectError: true,
},
{
name: "missing field_id",
mockedClient: mock.NewMockedHTTPClient(),
requestArgs: map[string]any{
"owner": "octo-org",
"owner_type": "org",
"projectNumber": int64(10),
},
expectError: true,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client := gh.NewClient(tc.mockedClient)
_, handler := GetProjectField(stubGetClientFn(client), translations.NullTranslationHelper)
request := createMCPRequest(tc.requestArgs)
result, err := handler(context.Background(), request)

require.NoError(t, err)
if tc.expectError {
require.True(t, result.IsError)
text := getTextResult(t, result).Text
if tc.expectedErrMsg != "" {
assert.Contains(t, text, tc.expectedErrMsg)
}
if tc.name == "missing owner" {
assert.Contains(t, text, "missing required parameter: owner")
}
if tc.name == "missing owner_type" {
assert.Contains(t, text, "missing required parameter: owner_type")
}
if tc.name == "missing projectNumber" {
assert.Contains(t, text, "missing required parameter: projectNumber")
}
if tc.name == "missing field_id" {
assert.Contains(t, text, "missing required parameter: field_id")
}
return
}

require.False(t, result.IsError)
textContent := getTextResult(t, result)
var field map[string]any
err = json.Unmarshal([]byte(textContent.Text), &field)
require.NoError(t, err)
if tc.expectedID != 0 {
assert.Equal(t, float64(tc.expectedID), field["id"])
}
})
}
}
Loading