diff --git a/README.md b/README.md index 3b0cd861f..ff3e44ba9 100644 --- a/README.md +++ b/README.md @@ -663,11 +663,30 @@ The following sets of tools are available (all are on by default): - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) +- **get_project_field** - Get project field + - `field_id`: The field's id. (number, required) + - `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) + - `project_number`: The project's number. (number, required) + +- **get_project_item** - Get project item + - `item_id`: The item's ID. (number, required) + - `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) + - `project_number`: The project's number. (number, required) + - **list_project_fields** - List project fields - `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) + - `project_number`: The project's number. (number, required) + +- **list_project_items** - List project items + - `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) + - `project_number`: The project's number. (number, required) + - `query`: Search query to filter items (string, optional) - **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) diff --git a/pkg/github/__toolsnaps__/get_project_field.snap b/pkg/github/__toolsnaps__/get_project_field.snap new file mode 100644 index 000000000..65d6f86f1 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_field.snap @@ -0,0 +1,39 @@ +{ + "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" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "field_id" + ], + "type": "object" + }, + "name": "get_project_field" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_item.snap b/pkg/github/__toolsnaps__/get_project_item.snap new file mode 100644 index 000000000..6f8f60935 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_item.snap @@ -0,0 +1,39 @@ +{ + "annotations": { + "title": "Get project item", + "readOnlyHint": true + }, + "description": "Get a specific Project item for a user or org", + "inputSchema": { + "properties": { + "item_id": { + "description": "The item'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" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "owner_type", + "owner", + "project_number", + "item_id" + ], + "type": "object" + }, + "name": "get_project_item" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_fields.snap b/pkg/github/__toolsnaps__/list_project_fields.snap index 3a293463e..0a2180e2b 100644 --- a/pkg/github/__toolsnaps__/list_project_fields.snap +++ b/pkg/github/__toolsnaps__/list_project_fields.snap @@ -22,15 +22,15 @@ "description": "Number of results per page (max 100, default: 30)", "type": "number" }, - "projectNumber": { + "project_number": { "description": "The project's number.", - "type": "string" + "type": "number" } }, "required": [ "owner_type", "owner", - "projectNumber" + "project_number" ], "type": "object" }, diff --git a/pkg/github/__toolsnaps__/list_project_items.snap b/pkg/github/__toolsnaps__/list_project_items.snap new file mode 100644 index 000000000..09b3267f0 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_project_items.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "title": "List project items", + "readOnlyHint": true + }, + "description": "List Project items for a user or org", + "inputSchema": { + "properties": { + "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" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + }, + "query": { + "description": "Search query to filter items", + "type": "string" + } + }, + "required": [ + "owner_type", + "owner", + "project_number" + ], + "type": "object" + }, + "name": "list_project_items" +} \ No newline at end of file diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 099b87481..59cab6b43 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -114,8 +114,97 @@ type MinimalResponse struct { URL string `json:"url"` } +type MinimalProject struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Owner *MinimalUser `json:"owner,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Public *bool `json:"public,omitempty"` + ClosedAt *github.Timestamp `json:"closed_at,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + DeletedAt *github.Timestamp `json:"deleted_at,omitempty"` + Number *int `json:"number,omitempty"` + ShortDescription *string `json:"short_description,omitempty"` + DeletedBy *MinimalUser `json:"deleted_by,omitempty"` +} + +type MinimalProjectItem struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectNodeID *string `json:"project_node_id,omitempty"` + ContentNodeID *string `json:"content_node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + ContentType *string `json:"content_type,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + Fields []*projectV2Field `json:"fields,omitempty"` +} + // Helper functions +func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { + if fullProject == nil { + return nil + } + + return &MinimalProject{ + ID: github.Ptr(fullProject.GetID()), + NodeID: github.Ptr(fullProject.GetNodeID()), + Owner: convertToMinimalUser(fullProject.GetOwner()), + Creator: convertToMinimalUser(fullProject.GetCreator()), + Title: github.Ptr(fullProject.GetTitle()), + Description: github.Ptr(fullProject.GetDescription()), + Public: github.Ptr(fullProject.GetPublic()), + ClosedAt: github.Ptr(fullProject.GetClosedAt()), + CreatedAt: github.Ptr(fullProject.GetCreatedAt()), + UpdatedAt: github.Ptr(fullProject.GetUpdatedAt()), + DeletedAt: github.Ptr(fullProject.GetDeletedAt()), + Number: github.Ptr(fullProject.GetNumber()), + ShortDescription: github.Ptr(fullProject.GetShortDescription()), + DeletedBy: convertToMinimalUser(fullProject.GetDeletedBy()), + } +} + +func convertToMinimalUser(user *github.User) *MinimalUser { + if user == nil { + return nil + } + + return &MinimalUser{ + Login: user.GetLogin(), + ID: user.GetID(), + ProfileURL: user.GetHTMLURL(), + AvatarURL: user.GetAvatarURL(), + } +} + +func convertToMinimalProjectItem(item *projectV2Item) *MinimalProjectItem { + if item == nil { + return nil + } + + return &MinimalProjectItem{ + ID: item.ID, + NodeID: item.NodeID, + ProjectNodeID: item.ProjectNodeID, + ContentNodeID: item.ContentNodeID, + ProjectURL: item.ProjectURL, + ContentType: item.ContentType, + Creator: convertToMinimalUser(item.Creator), + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + ArchivedAt: item.ArchivedAt, + ItemURL: item.ItemURL, + Fields: item.Fields, + } +} + // convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { minimalCommit := MinimalCommit{ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d4ab48844..f3ea0f7e1 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -54,15 +54,13 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( url = fmt.Sprintf("users/%s/projectsV2", owner) } projects := []github.ProjectV2{} + minimalProjects := []MinimalProject{} opts := listProjectsOptions{PerPage: perPage} if queryStr != "" { opts.Query = queryStr } - if perPage > 0 { - opts.PerPage = perPage - } url, err = addOptions(url, opts) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) @@ -83,6 +81,10 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } defer func() { _ = resp.Body.Close() }() + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(&project)) + } + if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { @@ -90,7 +92,7 @@ func ListProjects(getClient GetClientFn, t translations.TranslationHelperFunc) ( } return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil } - r, err := json.Marshal(projects) + r, err := json.Marshal(minimalProjects) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -159,7 +161,9 @@ func GetProject(getClient GetClientFn, t translations.TranslationHelperFunc) (to } return mcp.NewToolResultError(fmt.Sprintf("failed to get project: %s", string(body))), nil } - r, err := json.Marshal(project) + + minimalProject := convertToMinimalProject(&project) + r, err := json.Marshal(minimalProject) if err != nil { return nil, fmt.Errorf("failed to marshal response: %w", err) } @@ -174,7 +178,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("project_number", 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") @@ -185,7 +189,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu if err != nil { return mcp.NewToolResultError(err.Error()), nil } - projectNumber, err := RequiredParam[string](req, "projectNumber") + projectNumber, err := RequiredInt(req, "project_number") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -200,17 +204,13 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu var url string if ownerType == "org" { - url = fmt.Sprintf("orgs/%s/projectsV2/%s/fields", owner, projectNumber) + url = fmt.Sprintf("orgs/%s/projectsV2/%d/fields", owner, projectNumber) } else { - url = fmt.Sprintf("users/%s/projectsV2/%s/fields", owner, projectNumber) + url = fmt.Sprintf("users/%s/projectsV2/%d/fields", owner, projectNumber) } projectFields := []projectV2Field{} opts := listProjectsOptions{PerPage: perPage} - - if perPage > 0 { - opts.PerPage = perPage - } url, err = addOptions(url, opts) if err != nil { return nil, fmt.Errorf("failed to add options to request: %w", err) @@ -224,7 +224,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu resp, err := client.Do(ctx, httpRequest, &projectFields) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", + "failed to list project fields", resp, err, ), nil @@ -236,7 +236,7 @@ func ListProjectFields(getClient GetClientFn, t translations.TranslationHelperFu if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } - return mcp.NewToolResultError(fmt.Sprintf("failed to list projects: %s", string(body))), nil + return mcp.NewToolResultError(fmt.Sprintf("failed to list project fields: %s", string(body))), nil } r, err := json.Marshal(projectFields) if err != nil { @@ -247,6 +247,233 @@ 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("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("field_id", mcp.Required(), mcp.Description("The field's id.")), + ), 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + fieldID, err := RequiredInt(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 + } +} + +func ListProjectItems(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_project_items", + mcp.WithDescription(t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", "List Project items for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), 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("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithString("query", mcp.Description("Search query to filter items")), + 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") + 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := OptionalIntParamWithDefault(req, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + queryStr, err := OptionalParam[string](req, "query") + 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/items", owner, projectNumber) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items", owner, projectNumber) + } + projectItems := []projectV2Item{} + + opts := listProjectsOptions{PerPage: perPage} + if queryStr != "" { + opts.Query = queryStr + } + url, err = addOptions(url, opts) + if err != nil { + return nil, fmt.Errorf("failed to add options to request: %w", err) + } + + 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, &projectItems) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project items", + 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 list project items: %s", string(body))), nil + } + minimalProjectItems := []MinimalProjectItem{} + for _, item := range projectItems { + minimalProjectItems = append(minimalProjectItems, *convertToMinimalProjectItem(&item)) + } + r, err := json.Marshal(minimalProjectItems) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + +func GetProjectItem(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_project_item", + mcp.WithDescription(t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), 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("project_number", mcp.Required(), mcp.Description("The project's number.")), + mcp.WithNumber("item_id", mcp.Required(), mcp.Description("The item's ID.")), + ), 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 := RequiredInt(req, "project_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + itemID, err := RequiredInt(req, "item_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/items/%d", owner, projectNumber, itemID) + } else { + url = fmt.Sprintf("users/%s/projectsV2/%d/items/%d", owner, projectNumber, itemID) + } + projectItem := projectV2Item{} + + 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, &projectItem) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + 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 item: %s", string(body))), nil + } + r, err := json.Marshal(convertToMinimalProjectItem(&projectItem)) + 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. @@ -258,6 +485,21 @@ type projectV2Field struct { UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` // The time when this field was last updated. } +type projectV2Item struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + ProjectNodeID *string `json:"project_node_id,omitempty"` + ContentNodeID *string `json:"content_node_id,omitempty"` + ProjectURL *string `json:"project_url,omitempty"` + ContentType *string `json:"content_type,omitempty"` + Creator *github.User `json:"creator,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` + ArchivedAt *github.Timestamp `json:"archived_at,omitempty"` + ItemURL *string `json:"item_url,omitempty"` + Fields []*projectV2Field `json:"fields,omitempty"` +} + type listProjectsOptions struct { // For paginated result sets, the number of results to include per page. PerPage int `url:"per_page,omitempty"` diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 1ea19d18b..a6225ec86 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -300,9 +300,9 @@ func Test_ListProjectFields(t *testing.T) { 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, "project_number") assert.Contains(t, tool.InputSchema.Properties, "per_page") - assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "projectNumber"}) + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) orgFields := []map[string]any{ {"id": 101, "name": "Status", "dataType": "single_select"}, @@ -328,9 +328,9 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": "123", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), }, expectedLength: 1, }, @@ -352,10 +352,10 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octocat", - "owner_type": "user", - "projectNumber": "456", - "per_page": float64(50), + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "per_page": float64(50), }, expectedLength: 1, }, @@ -368,19 +368,19 @@ func Test_ListProjectFields(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "owner_type": "org", - "projectNumber": "789", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), }, expectError: true, - expectedErrMsg: "failed to list projects", + expectedErrMsg: "failed to list project fields", }, { name: "missing owner", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner_type": "org", - "projectNumber": "10", + "owner_type": "org", + "project_number": 10, }, expectError: true, }, @@ -388,13 +388,13 @@ func Test_ListProjectFields(t *testing.T) { name: "missing owner_type", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ - "owner": "octo-org", - "projectNumber": "10", + "owner": "octo-org", + "project_number": 10, }, expectError: true, }, { - name: "missing projectNumber", + name: "missing project_number", mockedClient: mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "octo-org", @@ -424,8 +424,8 @@ func Test_ListProjectFields(t *testing.T) { 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 project_number" { + assert.Contains(t, text, "missing required parameter: project_number") } return } @@ -439,3 +439,492 @@ 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "field_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "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", + "project_number": float64(123), + "field_id": float64(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", + "project_number": float64(456), + "field_id": float64(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", + "project_number": float64(789), + "field_id": float64(303), + }, + expectError: true, + expectedErrMsg: "failed to get project field", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(10), + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(10), + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "field_id": float64(1), + }, + expectError: true, + }, + { + name: "missing field_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(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 project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + 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"]) + } + }) + } +} + +func Test_ListProjectItems(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := ListProjectItems(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_items", 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "query") + assert.Contains(t, tool.InputSchema.Properties, "per_page") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number"}) + + orgItems := []map[string]any{ + {"id": 301, "content_type": "Issue", "project_node_id": "PR_1"}, + } + userItems := []map[string]any{ + {"id": 401, "content_type": "PullRequest", "project_node_id": "PR_2"}, + {"id": 402, "content_type": "DraftIssue", "project_node_id": "PR_3"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedLength int + expectedErrMsg string + }{ + { + name: "success organization items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + }, + expectedLength: 1, + }, + { + name: "success user items", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItems), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + }, + expectedLength: 2, + }, + { + name: "success with pagination and query", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("per_page") == "50" && q.Get("q") == "bug" { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgItems)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "per_page": float64(50), + "query": "bug", + }, + expectedLength: 1, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + }, + expectError: true, + expectedErrMsg: "failed to list project items", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "project_number": float64(10), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := ListProjectItems(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 project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var items []map[string]any + err = json.Unmarshal([]byte(textContent.Text), &items) + require.NoError(t, err) + assert.Equal(t, tc.expectedLength, len(items)) + }) + } +} + +func Test_GetProjectItem(t *testing.T) { + mockClient := gh.NewClient(nil) + tool, _ := GetProjectItem(stubGetClientFn(mockClient), translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_item", 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, "project_number") + assert.Contains(t, tool.InputSchema.Properties, "item_id") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner_type", "owner", "project_number", "item_id"}) + + orgItem := map[string]any{ + "id": 301, + "content_type": "Issue", + "project_node_id": "PR_1", + "creator": map[string]any{"login": "octocat"}, + } + userItem := map[string]any{ + "id": 501, + "content_type": "PullRequest", + "project_node_id": "PR_2", + "creator": map[string]any{"login": "jane"}, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedID int + }{ + { + name: "success organization item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, orgItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(123), + "item_id": float64(301), + }, + expectedID: 301, + }, + { + name: "success user item", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusOK, userItem), + ), + ), + requestArgs: map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(456), + "item_id": float64(501), + }, + expectedID: 501, + }, + { + name: "api error", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, + mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + ), + ), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(789), + "item_id": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get project item", + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner_type": "org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing owner_type", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "project_number": float64(10), + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing project_number", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "item_id": float64(1), + }, + expectError: true, + }, + { + name: "missing item_id", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(10), + }, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + _, handler := GetProjectItem(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 project_number" { + assert.Contains(t, text, "missing required parameter: project_number") + } + if tc.name == "missing item_id" { + assert.Contains(t, text, "missing required parameter: item_id") + } + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + var item map[string]any + err = json.Unmarshal([]byte(textContent.Text), &item) + require.NoError(t, err) + if tc.expectedID != 0 { + assert.Equal(t, float64(tc.expectedID), item["id"]) + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 2de9c23ca..eb6e657e3 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -195,6 +195,9 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(ListProjects(getClient, t)), toolsets.NewServerTool(GetProject(getClient, t)), toolsets.NewServerTool(ListProjectFields(getClient, t)), + toolsets.NewServerTool(GetProjectField(getClient, t)), + toolsets.NewServerTool(ListProjectItems(getClient, t)), + toolsets.NewServerTool(GetProjectItem(getClient, t)), ) // Add toolsets to the group