From 910185cd32162930c3196fd293897ee8a226cd11 Mon Sep 17 00:00:00 2001 From: e-straight Date: Mon, 9 Feb 2026 18:41:13 -0800 Subject: [PATCH 1/5] Add ProjectV2 status update tools (list, get, create) Closes https://github.com/github/github-mcp-server/issues/1963 Add three new individual tools and wire them into the consolidated project tools for managing GitHub ProjectV2 status updates: - list_project_status_updates / projects_list: List status updates for a project with pagination, ordered by creation date descending - get_project_status_update / projects_get: Fetch a single status update by node ID - create_project_status_update / projects_write: Create a status update with optional body, status, start_date, and target_date New GraphQL types and queries (statusUpdateNode, statusUpdatesUserQuery, statusUpdatesOrgQuery, statusUpdateNodeQuery) support both user-owned and org-owned projects. The CreateProjectV2StatusUpdateInput type is defined locally since the shurcooL/githubv4 library does not include it. Also includes quality improvements discovered during implementation: - Extract resolveProjectNodeID helper to deduplicate ~70 lines of project ID resolution logic shared between addProjectItem and createProjectStatusUpdate - Add client-side YYYY-MM-DD date format validation for start_date and target_date fields before sending to the API - Fix brittle node type check in getProjectStatusUpdate that relied on stringifying a githubv4.ID and comparing to "" - Refactor createProjectStatusUpdate to accept typed parameters instead of raw args map - Add deprecated tool aliases for all three new individual tools - Add ProjectResolveIDFailedError constant for consistent error reporting Test coverage includes 21 subtests covering both user and org paths, pagination, error handling, input validation, field verification, and consolidated tool dispatch. --- README.md | 7 +- docs/tool-renaming.md | 3 + .../create_project_status_update.snap | 56 + .../get_project_status_update.snap | 20 + .../list_project_status_updates.snap | 42 + pkg/github/__toolsnaps__/projects_get.snap | 7 +- pkg/github/__toolsnaps__/projects_list.snap | 5 +- pkg/github/__toolsnaps__/projects_write.snap | 28 +- pkg/github/deprecated_tool_aliases.go | 21 +- pkg/github/minimal_types.go | 10 + pkg/github/projects.go | 1621 ++++++++++++++++- pkg/github/projects_test.go | 1093 +++++++++++ pkg/github/toolset_instructions.go | 2 + 13 files changed, 2835 insertions(+), 80 deletions(-) create mode 100644 pkg/github/__toolsnaps__/create_project_status_update.snap create mode 100644 pkg/github/__toolsnaps__/get_project_status_update.snap create mode 100644 pkg/github/__toolsnaps__/list_project_status_updates.snap diff --git a/README.md b/README.md index f0c1a7401..008d974aa 100644 --- a/README.md +++ b/README.md @@ -986,6 +986,7 @@ The following sets of tools are available: - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - `project_number`: The project's number. (number, required) + - `status_update_id`: The node ID of the project status update. Required for 'get_project_status_update' method. (string, optional) - **projects_list** - List GitHub Projects resources - **Required OAuth Scopes**: `read:project` @@ -997,11 +998,12 @@ The following sets of tools are available: - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods. (number, optional) - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) - **projects_write** - Modify GitHub Project items - **Required OAuth Scopes**: `project` + - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) @@ -1012,6 +1014,9 @@ The following sets of tools are available: - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - `project_number`: The project's number. (number, required) - `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) + - `start_date`: The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) + - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) diff --git a/docs/tool-renaming.md b/docs/tool-renaming.md index 050ac9b77..0c5ffa1f4 100644 --- a/docs/tool-renaming.md +++ b/docs/tool-renaming.md @@ -48,12 +48,14 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. |----------|----------| | `add_project_item` | `projects_write` | | `cancel_workflow_run` | `actions_run_trigger` | +| `create_project_status_update` | `projects_write` | | `delete_project_item` | `projects_write` | | `delete_workflow_run_logs` | `actions_run_trigger` | | `download_workflow_run_artifact` | `actions_get` | | `get_project` | `projects_get` | | `get_project_field` | `projects_get` | | `get_project_item` | `projects_get` | +| `get_project_status_update` | `projects_get` | | `get_workflow` | `actions_get` | | `get_workflow_job` | `actions_get` | | `get_workflow_job_logs` | `actions_get` | @@ -62,6 +64,7 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | `get_workflow_run_usage` | `actions_get` | | `list_project_fields` | `projects_list` | | `list_project_items` | `projects_list` | +| `list_project_status_updates` | `projects_list` | | `list_projects` | `projects_list` | | `list_workflow_jobs` | `actions_list` | | `list_workflow_run_artifacts` | `actions_list` | diff --git a/pkg/github/__toolsnaps__/create_project_status_update.snap b/pkg/github/__toolsnaps__/create_project_status_update.snap new file mode 100644 index 000000000..8d07bf40e --- /dev/null +++ b/pkg/github/__toolsnaps__/create_project_status_update.snap @@ -0,0 +1,56 @@ +{ + "annotations": { + "title": "Create project status update" + }, + "description": "Create a status update for a GitHub project", + "inputSchema": { + "properties": { + "body": { + "description": "The body of the status update (markdown).", + "type": "string" + }, + "owner": { + "description": "The owner (user or organization login). 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" + }, + "start_date": { + "description": "The start date of the status update in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "description": "The status of the project.", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "The target date of the status update in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "owner", + "owner_type", + "project_number" + ], + "type": "object" + }, + "name": "create_project_status_update" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_status_update.snap b/pkg/github/__toolsnaps__/get_project_status_update.snap new file mode 100644 index 000000000..da8663e38 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_project_status_update.snap @@ -0,0 +1,20 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get project status update" + }, + "description": "Get a single project status update by ID", + "inputSchema": { + "properties": { + "status_update_id": { + "description": "The node ID of the project status update.", + "type": "string" + } + }, + "required": [ + "status_update_id" + ], + "type": "object" + }, + "name": "get_project_status_update" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_status_updates.snap b/pkg/github/__toolsnaps__/list_project_status_updates.snap new file mode 100644 index 000000000..85fdf4d3d --- /dev/null +++ b/pkg/github/__toolsnaps__/list_project_status_updates.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List project status updates" + }, + "description": "List status updates for a GitHub project", + "inputSchema": { + "properties": { + "after": { + "description": "Forward pagination cursor from previous pageInfo.nextCursor.", + "type": "string" + }, + "owner": { + "description": "The owner (user or organization login). The name is not case sensitive.", + "type": "string" + }, + "owner_type": { + "description": "Owner type", + "enum": [ + "user", + "org" + ], + "type": "string" + }, + "per_page": { + "description": "Results per page (max 50)", + "type": "number" + }, + "project_number": { + "description": "The project's number.", + "type": "number" + } + }, + "required": [ + "owner", + "owner_type", + "project_number" + ], + "type": "object" + }, + "name": "list_project_status_updates" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index cb5013d74..cfb4b5829 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -26,7 +26,8 @@ "enum": [ "get_project", "get_project_field", - "get_project_item" + "get_project_item", + "get_project_status_update" ], "type": "string" }, @@ -45,6 +46,10 @@ "project_number": { "description": "The project's number.", "type": "number" + }, + "status_update_id": { + "description": "The node ID of the project status update. Required for 'get_project_status_update' method.", + "type": "string" } }, "required": [ diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index f12452b5a..c2bb0d3f4 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -26,7 +26,8 @@ "enum": [ "list_projects", "list_project_fields", - "list_project_items" + "list_project_items", + "list_project_status_updates" ], "type": "string" }, @@ -47,7 +48,7 @@ "type": "number" }, "project_number": { - "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + "description": "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", "type": "number" }, "query": { diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index d2d871bcd..f6d3197b8 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -3,9 +3,13 @@ "destructiveHint": true, "title": "Modify GitHub Project items" }, - "description": "Add, update, or delete project items in a GitHub Project.", + "description": "Add, update, or delete project items, or create status updates in a GitHub Project.", "inputSchema": { "properties": { + "body": { + "description": "The body of the status update (markdown). Used for 'create_project_status_update' method.", + "type": "string" + }, "issue_number": { "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" @@ -35,7 +39,8 @@ "enum": [ "add_project_item", "update_project_item", - "delete_project_item" + "delete_project_item", + "create_project_status_update" ], "type": "string" }, @@ -59,6 +64,25 @@ "description": "The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" }, + "start_date": { + "description": "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "type": "string" + }, + "status": { + "description": "The status of the project. Used for 'create_project_status_update' method.", + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "type": "string" + }, "updated_field": { "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", "type": "object" diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 4415731fb..5845c55a4 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -30,13 +30,16 @@ var DeprecatedToolAliases = map[string]string{ "delete_workflow_run_logs": "actions_run_trigger", // Projects tools consolidated - "list_projects": "projects_list", - "list_project_fields": "projects_list", - "list_project_items": "projects_list", - "get_project": "projects_get", - "get_project_field": "projects_get", - "get_project_item": "projects_get", - "add_project_item": "projects_write", - "update_project_item": "projects_write", - "delete_project_item": "projects_write", + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", + "list_project_status_updates": "projects_list", + "get_project_status_update": "projects_get", + "create_project_status_update": "projects_write", } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a33bcec7a..4e5559ee2 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -134,6 +134,16 @@ type MinimalProject struct { OwnerType string `json:"owner_type,omitempty"` } +type MinimalProjectStatusUpdate struct { + ID string `json:"id"` + Body string `json:"body,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + StartDate string `json:"start_date,omitempty"` + TargetDate string `json:"target_date,omitempty"` + Creator *MinimalUser `json:"creator,omitempty"` +} + // Helper functions func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d2ab05008..5e04e53b8 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" @@ -19,26 +20,1284 @@ import ( ) const ( - ProjectUpdateFailedError = "failed to update a project item" - ProjectAddFailedError = "failed to add a project item" - ProjectDeleteFailedError = "failed to delete a project item" - ProjectListFailedError = "failed to list project items" - MaxProjectsPerPage = 50 + ProjectUpdateFailedError = "failed to update a project item" + ProjectAddFailedError = "failed to add a project item" + ProjectDeleteFailedError = "failed to delete a project item" + ProjectListFailedError = "failed to list project items" + ProjectStatusUpdateListFailedError = "failed to list project status updates" + ProjectStatusUpdateGetFailedError = "failed to get project status update" + ProjectStatusUpdateCreateFailedError = "failed to create project status update" + ProjectResolveIDFailedError = "failed to resolve project ID" + MaxProjectsPerPage = 50 ) +// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to +// individual project tools instead of the consolidated project tools. +const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects" + // Method constants for consolidated project tools const ( - projectsMethodListProjects = "list_projects" - projectsMethodListProjectFields = "list_project_fields" - projectsMethodListProjectItems = "list_project_items" - projectsMethodGetProject = "get_project" - projectsMethodGetProjectField = "get_project_field" - projectsMethodGetProjectItem = "get_project_item" - projectsMethodAddProjectItem = "add_project_item" - projectsMethodUpdateProjectItem = "update_project_item" - projectsMethodDeleteProjectItem = "delete_project_item" + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" + projectsMethodListProjectStatusUpdates = "list_project_status_updates" + projectsMethodGetProjectStatusUpdate = "get_project_status_update" + projectsMethodCreateProjectStatusUpdate = "create_project_status_update" ) +// GraphQL types for ProjectV2 status updates + +type statusUpdateNode struct { + ID githubv4.ID + Body githubv4.String + Status githubv4.String + CreatedAt githubv4.DateTime + StartDate githubv4.String + TargetDate githubv4.String + Creator struct { + Login githubv4.String + } +} + +type statusUpdateConnection struct { + Nodes []statusUpdateNode + PageInfo PageInfoFragment +} + +// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project. +type statusUpdatesUserQuery struct { + User struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project. +type statusUpdatesOrgQuery struct { + Organization struct { + ProjectV2 struct { + StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID. +type statusUpdateNodeQuery struct { + Node struct { + StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"` + } `graphql:"node(id: $id)"` +} + +// CreateProjectV2StatusUpdateInput is the input for the createProjectV2StatusUpdate mutation. +// Defined locally because the shurcooL/githubv4 library does not include this type. +type CreateProjectV2StatusUpdateInput struct { + ProjectID githubv4.ID `json:"projectId"` + Body *githubv4.String `json:"body,omitempty"` + Status *githubv4.String `json:"status,omitempty"` + StartDate *githubv4.String `json:"startDate,omitempty"` + TargetDate *githubv4.String `json:"targetDate,omitempty"` + ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` +} + +// validProjectV2StatusUpdateStatuses is the set of valid status values for the createProjectV2StatusUpdate mutation. +var validProjectV2StatusUpdateStatuses = map[string]bool{ + "INACTIVE": true, + "ON_TRACK": true, + "AT_RISK": true, + "OFF_TRACK": true, + "COMPLETE": true, +} + +func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpdate { + var creator *MinimalUser + if login := string(node.Creator.Login); login != "" { + creator = &MinimalUser{Login: login} + } + + return MinimalProjectStatusUpdate{ + ID: fmt.Sprintf("%v", node.ID), + Body: string(node.Body), + Status: string(node.Status), + CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), + StartDate: string(node.StartDate), + TargetDate: string(node.TargetDate), + Creator: creator, + } +} + +func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "list_projects", + Description: t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "query": { + Type: "string", + Description: `Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"owner_type", "owner"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + } + + if ownerType == "org" { + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "get_project", + Description: t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "project_number": { + Type: "number", + Description: "The project's number", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + }, + Required: []string{"project_number", "owner_type", "owner"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var project *github.ProjectV2 + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "list_project_fields", + Description: t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"owner_type", "owner", "project_number"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "get_project_field", + Description: t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's id.", + }, + }, + Required: []string{"owner_type", "owner", "project_number", "field_id"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectField *github.ProjectV2Field + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "list_project_items", + Description: t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "query": { + Type: "string", + Description: `Query string for advanced filtering of project items using GitHub's project filtering syntax.`, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + "fields": { + Type: "array", + Description: "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"owner_type", "owner", "project_number"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectItems []*github.ProjectV2Item + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + opts := &github.ListProjectItemsOptions{ + Fields: fields, + ListProjectsOptions: github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "get_project_item", + Description: t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The item's ID.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"owner_type", "owner", "project_number", "item_id"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectItem *github.ProjectV2Item + var opts *github.GetProjectItemOptions + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(projectItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "add_project_item", + Description: t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_type": { + Type: "string", + Description: "The item's type, either issue or pull_request.", + Enum: []any{"issue", "pull_request"}, + }, + "item_id": { + Type: "number", + Description: "The numeric ID of the issue or pull request to add to the project.", + }, + }, + Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if itemType != "issue" && itemType != "pull_request" { + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + newItem := &github.AddProjectItemOptions{ + ID: itemID, + Type: toNewProjectType(itemType), + } + + var resp *github.Response + var addedItem *github.ProjectV2Item + + if ownerType == "org" { + addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) + } else { + addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectAddFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil + } + r, err := json.Marshal(addedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "update_project_item", + Description: t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The unique identifier of the project item. This is not the issue or pull request ID.", + }, + "updated_field": { + Type: "object", + Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", + }, + }, + Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + rawUpdatedField, exists := args["updated_field"] + if !exists { + return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil + } + + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return utils.NewToolResultError("field_value must be an object"), nil, nil + } + + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "delete_project_item", + Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + 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.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The internal project item ID to delete from the project (not the issue or pull request ID).", + }, + }, + Required: []string{"owner_type", "owner", "project_number", "item_id"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func ListProjectStatusUpdates(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "list_project_status_updates", + Description: t("TOOL_LIST_PROJECT_STATUS_UPDATES_DESCRIPTION", "List status updates for a GitHub project"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_PROJECT_STATUS_UPDATES_USER_TITLE", "List project status updates"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + }, + Required: []string{"owner", "owner_type", "project_number"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func GetProjectStatusUpdate(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "get_project_status_update", + Description: t("TOOL_GET_PROJECT_STATUS_UPDATE_DESCRIPTION", "Get a single project status update by ID"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_PROJECT_STATUS_UPDATE_USER_TITLE", "Get project status update"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "status_update_id": { + Type: "string", + Description: "The node ID of the project status update.", + }, + }, + Required: []string{"status_update_id"}, + }, + }, + []scopes.Scope{scopes.ReadProject}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + statusUpdateID, err := RequiredParam[string](args, "status_update_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + +func CreateProjectStatusUpdate(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "create_project_status_update", + Description: t("TOOL_CREATE_PROJECT_STATUS_UPDATE_DESCRIPTION", "Create a status update for a GitHub project"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_PROJECT_STATUS_UPDATE_USER_TITLE", "Create project status update"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner (user or organization login). The name is not case sensitive.", + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "body": { + Type: "string", + Description: "The body of the status update (markdown).", + }, + "status": { + Type: "string", + Description: "The status of the project.", + Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, + }, + "start_date": { + Type: "string", + Description: "The start date of the status update in YYYY-MM-DD format.", + }, + "target_date": { + Type: "string", + Description: "The target date of the status update in YYYY-MM-DD format.", + }, + }, + Required: []string{"owner", "owner_type", "project_number"}, + }, + }, + []scopes.Scope{scopes.Project}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := OptionalParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + status, err := OptionalParam[string](args, "status") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDate, err := OptionalParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + targetDate, err := OptionalParam[string](args, "target_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) + }, + ) + tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects + return tool +} + // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( @@ -63,6 +1322,7 @@ Use this tool to list projects for a user or organization, or list project field projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, + projectsMethodListProjectStatusUpdates, }, }, "owner_type": { @@ -76,7 +1336,7 @@ Use this tool to list projects for a user or organization, or list project field }, "project_number": { Type: "number", - Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + Description: "The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods.", }, "query": { Type: "string", @@ -130,8 +1390,8 @@ Use this tool to list projects for a user or organization, or list project field switch method { case projectsMethodListProjects: return listProjects(ctx, client, args, owner, ownerType) - case projectsMethodListProjectFields: - // Detect owner type if not provided and project_number is available + default: + // All other methods require project_number and ownerType detection if ownerType == "" { projectNumber, err := RequiredInt(args, "project_number") if err != nil { @@ -142,22 +1402,21 @@ Use this tool to list projects for a user or organization, or list project field return utils.NewToolResultError(err.Error()), nil, nil } } - return listProjectFields(ctx, client, args, owner, ownerType) - case projectsMethodListProjectItems: - // Detect owner type if not provided and project_number is available - if ownerType == "" { - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err = detectOwnerType(ctx, client, owner, projectNumber) + + switch method { + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + case projectsMethodListProjectStatusUpdates: + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - return listProjectItems(ctx, client, args, owner, ownerType) - default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, ) @@ -187,6 +1446,7 @@ Use this tool to get details about individual projects, project fields, and proj projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, + projectsMethodGetProjectStatusUpdate, }, }, "owner_type": { @@ -217,6 +1477,10 @@ Use this tool to get details about individual projects, project fields, and proj Type: "string", }, }, + "status_update_id": { + Type: "string", + Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", + }, }, Required: []string{"method", "owner", "project_number"}, }, @@ -228,6 +1492,19 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } + // Handle get_project_status_update early — it only needs status_update_id + if method == projectsMethodGetProjectStatusUpdate { + statusUpdateID, err := RequiredParam[string](args, "status_update_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + } + owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -289,7 +1566,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { ToolsetMetadataProjects, mcp.Tool{ Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items, or create status updates in a GitHub Project."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), ReadOnlyHint: false, @@ -305,6 +1582,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, + projectsMethodCreateProjectStatusUpdate, }, }, "owner_type": { @@ -349,6 +1627,23 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "object", Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", }, + "body": { + Type: "string", + Description: "The body of the status update (markdown). Used for 'create_project_status_update' method.", + }, + "status": { + Type: "string", + Description: "The status of the project. Used for 'create_project_status_update' method.", + Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, + }, + "start_date": { + Type: "string", + Description: "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + }, + "target_date": { + Type: "string", + Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + }, }, Required: []string{"method", "owner", "project_number"}, }, @@ -445,6 +1740,24 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + case projectsMethodCreateProjectStatusUpdate: + body, err := OptionalParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + status, err := OptionalParam[string](args, "status") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDate, err := OptionalParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + targetDate, err := OptionalParam[string](args, "target_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -875,6 +2188,43 @@ func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerT return utils.NewToolResultText("project item successfully deleted"), nil, nil } +// resolveProjectNodeID resolves (owner, ownerType, projectNumber) to a project node ID via GraphQL. +func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) (githubv4.ID, error) { + var projectIDQueryUser struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + } + var projectIDQueryOrg struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + } + + queryVars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + } + + if ownerType == "org" { + err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars) + if err != nil { + return nil, fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryOrg.Organization.ProjectV2.ID, nil + } + + err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars) + if err != nil { + return nil, fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + } + return projectIDQueryUser.User.ProjectV2.ID, nil +} + // addProjectItem adds an item to a project by resolving the issue/PR number to a node ID func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemOwner, itemRepo string, itemNumber int, itemType string) (*mcp.CallToolResult, any, error) { if itemType != "issue" && itemType != "pull_request" { @@ -902,41 +2252,10 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne } `graphql:"addProjectV2ItemById(input: $input)"` } - // First, get the project ID - var projectIDQuery struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - } - var projectIDQueryOrg struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - } - - var projectID githubv4.ID - if ownerType == "org" { - err = gqlClient.Query(ctx, &projectIDQueryOrg, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQueryOrg.Organization.ProjectV2.ID - } else { - err = gqlClient.Query(ctx, &projectIDQuery, map[string]any{ - "owner": githubv4.String(owner), - "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers - }) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil - } - projectID = projectIDQuery.User.ProjectV2.ID + // Resolve the project number to a node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } // Add the item to the project @@ -963,6 +2282,178 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne return utils.NewToolResultText(string(r)), nil, nil } +// validateDateFormat checks that a date string is in YYYY-MM-DD format. +func validateDateFormat(value, fieldName string) error { + if _, err := time.Parse("2006-01-02", value); err != nil { + return fmt.Errorf("invalid %s %q: must be YYYY-MM-DD format", fieldName, value) + } + return nil +} + +// createProjectStatusUpdate creates a new status update for a project via GraphQL. +func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) { + // Validate inputs + if status != "" && !validProjectV2StatusUpdateStatuses[status] { + return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil + } + if startDate != "" { + if err := validateDateFormat(startDate, "start_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + if targetDate != "" { + if err := validateDateFormat(targetDate, "target_date"); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + + // Resolve project number to project node ID + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Build mutation input + input := CreateProjectV2StatusUpdateInput{ + ProjectID: projectID, + } + + if body != "" { + s := githubv4.String(body) + input.Body = &s + } + if status != "" { + s := githubv4.String(status) + input.Status = &s + } + if startDate != "" { + s := githubv4.String(startDate) + input.StartDate = &s + } + if targetDate != "" { + s := githubv4.String(targetDate) + input.TargetDate = &s + } + + // Execute mutation + var mutation struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateCreateFailedError, err)), nil, nil + } + + // Convert and return + result := convertToMinimalStatusUpdate(mutation.CreateProjectV2StatusUpdate.StatusUpdate) + + r, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +// listProjectStatusUpdates lists status updates for a project via GraphQL. +func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if perPage > MaxProjectsPerPage { + perPage = MaxProjectsPerPage + } + + afterCursor, err := OptionalParam[string](args, "after") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small integers + "first": githubv4.Int(int32(perPage)), //nolint:gosec // perPage is bounded by MaxProjectsPerPage + } + if afterCursor != "" { + vars["after"] = githubv4.String(afterCursor) + } else { + vars["after"] = (*githubv4.String)(nil) + } + + var nodes []statusUpdateNode + var pi PageInfoFragment + + if ownerType == "org" { + var q statusUpdatesOrgQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.Organization.ProjectV2.StatusUpdates.Nodes + pi = q.Organization.ProjectV2.StatusUpdates.PageInfo + } else { + var q statusUpdatesUserQuery + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil + } + nodes = q.User.ProjectV2.StatusUpdates.Nodes + pi = q.User.ProjectV2.StatusUpdates.PageInfo + } + + updates := make([]MinimalProjectStatusUpdate, 0, len(nodes)) + for _, n := range nodes { + updates = append(updates, convertToMinimalStatusUpdate(n)) + } + + response := map[string]any{ + "statusUpdates": updates, + "pageInfo": map[string]any{ + "hasNextPage": pi.HasNextPage, + "hasPreviousPage": pi.HasPreviousPage, + "nextCursor": string(pi.EndCursor), + "prevCursor": string(pi.StartCursor), + }, + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + +// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL. +func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) { + var q statusUpdateNodeQuery + vars := map[string]any{ + "id": githubv4.ID(statusUpdateID), + } + + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil + } + + if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), nil, nil + } + + update := convertToMinimalStatusUpdate(q.Node.StatusUpdate) + + r, err := json.Marshal(update) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + return utils.NewToolResultText(string(r)), nil, nil +} + type pageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 7c8f4a46f..d4c3d7229 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -814,3 +814,1096 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) } + +func Test_ListProjectStatusUpdates(t *testing.T) { + serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_project_status_updates", tool.Name) + assert.NotEmpty(t, tool.Description) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be a *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "owner_type") + assert.Contains(t, schema.Properties, "project_number") + assert.Contains(t, schema.Properties, "per_page") + assert.Contains(t, schema.Properties, "after") + assert.ElementsMatch(t, schema.Required, []string{"owner", "owner_type", "project_number"}) + + t.Run("success user project", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_1", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + { + "id": "SU_2", + "body": "At risk", + "status": "AT_RISK", + "createdAt": "2026-01-10T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "cursor1", + "endCursor": "cursor2", + }, + }, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 2) + _, hasPageInfo := response["pageInfo"].(map[string]any) + assert.True(t, hasPageInfo) + + // Verify actual content of returned status updates + firstUpdate, ok := updates[0].(map[string]any) + require.True(t, ok) + assert.Equal(t, "SU_1", firstUpdate["id"]) + assert.Equal(t, "On track", firstUpdate["body"]) + assert.Equal(t, "ON_TRACK", firstUpdate["status"]) + assert.Equal(t, "2026-01-01", firstUpdate["start_date"]) + assert.Equal(t, "2026-03-01", firstUpdate["target_date"]) + creator, ok := firstUpdate["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "octocat", creator["login"]) + }) + + t.Run("success org project", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesOrgQuery{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(5), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_3", + "body": "Off track", + "status": "OFF_TRACK", + "createdAt": "2026-02-01T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-04-01", + "creator": map[string]any{"login": "admin"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(5), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) + }) + + t.Run("success with pagination cursor", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(10), + "after": githubv4.String("cursor_abc"), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": true, + "startCursor": "cursor_abc", + "endCursor": "cursor_def", + }, + }, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(1), + "per_page": float64(10), + "after": "cursor_abc", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 0) + pageInfo, ok := response["pageInfo"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, pageInfo["hasPreviousPage"]) + assert.Equal(t, "cursor_def", pageInfo["nextCursor"]) + assert.Equal(t, "cursor_abc", pageInfo["prevCursor"]) + // Verify old field names are NOT present + _, hasEndCursor := pageInfo["endCursor"] + assert.False(t, hasEndCursor, "should use nextCursor, not endCursor") + _, hasStartCursor := pageInfo["startCursor"] + assert.False(t, hasStartCursor, "should use prevCursor, not startCursor") + }) + + t.Run("per_page exceeding max is capped", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), // Should be capped to MaxProjectsPerPage (50) + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}, + }, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(1), + "per_page": float64(999), // Exceeds max, should be capped + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + }) + + t.Run("graphql error", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.ErrorResponse("something went wrong"), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "failed to list project status updates") + }) + + t.Run("missing required params", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + + // Missing owner + request := createMCPRequest(map[string]any{ + "owner_type": "user", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: owner") + + // Missing owner_type + request = createMCPRequest(map[string]any{ + "owner": "octocat", + "project_number": float64(1), + }) + result, err = handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: owner_type") + + // Missing project_number + request = createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + }) + result, err = handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: project_number") + }) +} + +func Test_GetProjectStatusUpdate(t *testing.T) { + serverTool := GetProjectStatusUpdate(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "get_project_status_update", tool.Name) + assert.NotEmpty(t, tool.Description) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be a *jsonschema.Schema") + assert.Contains(t, schema.Properties, "status_update_id") + assert.ElementsMatch(t, schema.Required, []string{"status_update_id"}) + + t.Run("success", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "Making progress", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "status_update_id": "SU_abc123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "SU_abc123", response["id"]) + assert.Equal(t, "Making progress", response["body"]) + assert.Equal(t, "ON_TRACK", response["status"]) + assert.Equal(t, "2026-01-01", response["start_date"]) + assert.Equal(t, "2026-03-01", response["target_date"]) + assert.Contains(t, response["created_at"], "2026-01-15") + creator, ok := response["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "octocat", creator["login"]) + }) + + t.Run("graphql error", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_bad"), + }, + githubv4mock.ErrorResponse("node not found"), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "status_update_id": "SU_bad", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "failed to get project status update") + }) + + t.Run("missing status_update_id", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{}) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: status_update_id") + }) + + t.Run("not a status update node", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("ISSUE_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{}, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "status_update_id": "ISSUE_abc123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "node is not a ProjectV2StatusUpdate") + }) +} + +func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + // REST mock for detectOwnerType (when owner_type is omitted) + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), + }) + + // GQL mock for listProjectStatusUpdates + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_1", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + Client: gh.NewClient(restClient), + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_status_updates", + "owner": "octocat", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) + }) +} + +func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdateNodeQuery{}, + map[string]any{ + "id": githubv4.ID("SU_abc123"), + }, + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_status_update", + "owner": "octocat", + "project_number": float64(1), + "status_update_id": "SU_abc123", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "SU_abc123", response["id"]) + assert.Equal(t, "On track", response["body"]) + }) +} + +func Test_CreateProjectStatusUpdate(t *testing.T) { + serverTool := CreateProjectStatusUpdate(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "create_project_status_update", tool.Name) + assert.NotEmpty(t, tool.Description) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be a *jsonschema.Schema") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "owner_type") + assert.Contains(t, schema.Properties, "project_number") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "status") + assert.Contains(t, schema.Properties, "start_date") + assert.Contains(t, schema.Properties, "target_date") + assert.ElementsMatch(t, schema.Required, []string{"owner", "owner_type", "project_number"}) + + t.Run("success with all fields", func(t *testing.T) { + bodyStr := githubv4.String("Project is going well") + statusStr := githubv4.String("ON_TRACK") + startDateStr := githubv4.String("2026-01-01") + targetDateStr := githubv4.String("2026-06-30") + + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(2), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project2", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project2"), + Body: &bodyStr, + Status: &statusStr, + StartDate: &startDateStr, + TargetDate: &targetDateStr, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_su001", + "body": "Project is going well", + "status": "ON_TRACK", + "createdAt": "2026-02-09T12:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-06-30", + "creator": map[string]any{"login": "octocat"}, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + "body": "Project is going well", + "status": "ON_TRACK", + "start_date": "2026-01-01", + "target_date": "2026-06-30", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTSU_su001", response["id"]) + assert.Equal(t, "Project is going well", response["body"]) + assert.Equal(t, "ON_TRACK", response["status"]) + assert.Equal(t, "2026-01-01", response["start_date"]) + assert.Equal(t, "2026-06-30", response["target_date"]) + creator, ok := response["creator"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "octocat", creator["login"]) + }) + + t.Run("success with minimal fields", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(2), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project2", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation with minimal input + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project2"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_su002", + "createdAt": "2026-02-09T12:00:00Z", + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTSU_su002", response["id"]) + }) + + t.Run("invalid status", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + "status": "INVALID_STATUS", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "invalid status") + }) + + t.Run("success org owner_type", func(t *testing.T) { + bodyStr := githubv4.String("Org project update") + statusStr := githubv4.String("ON_TRACK") + + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for org + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_org_project5", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_org_project5"), + Body: &bodyStr, + Status: &statusStr, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_org_su001", + "body": "Org project update", + "status": "ON_TRACK", + "createdAt": "2026-02-09T12:00:00Z", + "creator": map[string]any{"login": "admin"}, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(5), + "body": "Org project update", + "status": "ON_TRACK", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTSU_org_su001", response["id"]) + assert.Equal(t, "Org project update", response["body"]) + assert.Equal(t, "ON_TRACK", response["status"]) + }) + + t.Run("graphql mutation error", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user - succeeds + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(2), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project2", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation - fails + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project2"), + }, + nil, + githubv4mock.ErrorResponse("mutation failed: insufficient permissions"), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, ProjectStatusUpdateCreateFailedError) + }) + + t.Run("project ID resolution failure", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user - fails + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("nonexistent"), + "projectNumber": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to a User with the login of 'nonexistent'"), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "nonexistent", + "owner_type": "user", + "project_number": float64(999), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, ProjectResolveIDFailedError) + }) + + t.Run("invalid start_date format", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + "start_date": "not-a-date", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "invalid start_date") + assert.Contains(t, textContent.Text, "YYYY-MM-DD") + }) + + t.Run("invalid target_date format", func(t *testing.T) { + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(2), + "target_date": "01/15/2026", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "invalid target_date") + assert.Contains(t, textContent.Text, "YYYY-MM-DD") + }) +} + +func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + bodyStr := githubv4.String("Consolidated test") + statusStr := githubv4.String("AT_RISK") + + gqlMockedClient := githubv4mock.NewMockedHTTPClient( + // Mock project ID query for user + githubv4mock.NewQueryMatcher( + struct { + User struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(3), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project3", + }, + }, + }), + ), + // Mock createProjectV2StatusUpdate mutation + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2StatusUpdate struct { + StatusUpdate statusUpdateNode + } `graphql:"createProjectV2StatusUpdate(input: $input)"` + }{}, + CreateProjectV2StatusUpdateInput{ + ProjectID: githubv4.ID("PVT_project3"), + Body: &bodyStr, + Status: &statusStr, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2StatusUpdate": map[string]any{ + "statusUpdate": map[string]any{ + "id": "PVTSU_su003", + "body": "Consolidated test", + "status": "AT_RISK", + "createdAt": "2026-02-09T12:00:00Z", + "creator": map[string]any{"login": "octocat"}, + }, + }, + }), + ), + ) + + gqlClient := githubv4.NewClient(gqlMockedClient) + deps := BaseDeps{ + GQLClient: gqlClient, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project_status_update", + "owner": "octocat", + "owner_type": "user", + "project_number": float64(3), + "body": "Consolidated test", + "status": "AT_RISK", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTSU_su003", response["id"]) + assert.Equal(t, "Consolidated test", response["body"]) + assert.Equal(t, "AT_RISK", response["status"]) + }) +} diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index bf2388a3d..bc9da4e65 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -39,6 +39,8 @@ func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. +Status updates: Use list_project_status_updates to read recent project status updates (newest first). Use get_project_status_update with a node ID to get a single update. Use create_project_status_update to create a new status update for a project. + Field usage: - Call list_project_fields first to understand available fields and get IDs/types before filtering. - Use EXACT returned field names (case-insensitive match). Don't invent names or IDs. From fdbf83483cb5f53397d3141cdf7e5a932dd743b1 Mon Sep 17 00:00:00 2001 From: e-straight Date: Wed, 11 Feb 2026 14:13:23 -0800 Subject: [PATCH 2/5] Fix projects_get required params and harden status update tools Loosen projects_get schema to only require "method", since get_project_status_update only needs status_update_id and never uses owner or project_number. Also use pointer types for optional statusUpdateNode fields, add owner_type validation for list/create status updates, clamp negative per_page values, and fix resolveProjectNodeID to return "" instead of nil on error. --- pkg/github/__toolsnaps__/projects_get.snap | 4 +- pkg/github/projects.go | 39 +++++-- pkg/github/projects_test.go | 115 ++++++++++++++++++++- 3 files changed, 143 insertions(+), 15 deletions(-) diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index cfb4b5829..864f61d83 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -53,9 +53,7 @@ } }, "required": [ - "method", - "owner", - "project_number" + "method" ], "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 5e04e53b8..56ba434e0 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -55,11 +55,11 @@ const ( type statusUpdateNode struct { ID githubv4.ID - Body githubv4.String - Status githubv4.String + Body *githubv4.String + Status *githubv4.String CreatedAt githubv4.DateTime - StartDate githubv4.String - TargetDate githubv4.String + StartDate *githubv4.String + TargetDate *githubv4.String Creator struct { Login githubv4.String } @@ -123,15 +123,22 @@ func convertToMinimalStatusUpdate(node statusUpdateNode) MinimalProjectStatusUpd return MinimalProjectStatusUpdate{ ID: fmt.Sprintf("%v", node.ID), - Body: string(node.Body), - Status: string(node.Status), + Body: derefString(node.Body), + Status: derefString(node.Status), CreatedAt: node.CreatedAt.Time.Format(time.RFC3339), - StartDate: string(node.StartDate), - TargetDate: string(node.TargetDate), + StartDate: derefString(node.StartDate), + TargetDate: derefString(node.TargetDate), Creator: creator, } } +func derefString(s *githubv4.String) string { + if s == nil { + return "" + } + return string(*s) +} + func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( ToolsetMetadataProjects, @@ -1482,7 +1489,7 @@ Use this tool to get details about individual projects, project fields, and proj Description: "The node ID of the project status update. Required for 'get_project_status_update' method.", }, }, - Required: []string{"method", "owner", "project_number"}, + Required: []string{"method"}, }, }, []scopes.Scope{scopes.ReadProject}, @@ -2213,14 +2220,14 @@ func resolveProjectNodeID(ctx context.Context, gqlClient *githubv4.Client, owner if ownerType == "org" { err := gqlClient.Query(ctx, &projectIDQueryOrg, queryVars) if err != nil { - return nil, fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) } return projectIDQueryOrg.Organization.ProjectV2.ID, nil } err := gqlClient.Query(ctx, &projectIDQueryUser, queryVars) if err != nil { - return nil, fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) + return "", fmt.Errorf("%s: %w", ProjectResolveIDFailedError, err) } return projectIDQueryUser.User.ProjectV2.ID, nil } @@ -2293,6 +2300,9 @@ func validateDateFormat(value, fieldName string) error { // createProjectStatusUpdate creates a new status update for a project via GraphQL. func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, body, status, startDate, targetDate string) (*mcp.CallToolResult, any, error) { // Validate inputs + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } if status != "" && !validProjectV2StatusUpdateStatuses[status] { return utils.NewToolResultError(fmt.Sprintf("invalid status %q: must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE", status)), nil, nil } @@ -2360,6 +2370,10 @@ func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, // listProjectStatusUpdates lists status updates for a project via GraphQL. func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -2372,6 +2386,9 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a if perPage > MaxProjectsPerPage { perPage = MaxProjectsPerPage } + if perPage < 1 { + perPage = MaxProjectsPerPage + } afterCursor, err := OptionalParam[string](args, "after") if err != nil { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index d4c3d7229..619933e22 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -236,7 +236,7 @@ func Test_ProjectsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties, "project_number") assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -1907,3 +1907,116 @@ func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { assert.Equal(t, "AT_RISK", response["status"]) }) } + +func Test_ListProjectStatusUpdates_NegativePerPage(t *testing.T) { + serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) + + // With a negative per_page, the handler should clamp to MaxProjectsPerPage (50) + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + statusUpdatesUserQuery{}, + map[string]any{ + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), // clamped to default + "after": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "projectV2": map[string]any{ + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_neg", + "body": "Negative per_page test", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "cursor1", + "endCursor": "cursor1", + }, + }, + }, + }, + }), + ), + ) + + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "user", + "project_number": float64(1), + "per_page": float64(-5), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) +} + +func Test_CreateProjectStatusUpdate_InvalidOwnerType(t *testing.T) { + serverTool := CreateProjectStatusUpdate(translations.NullTranslationHelper) + + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "invalid", + "project_number": float64(2), + "body": "Test", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, `invalid owner_type "invalid"`) +} + +func Test_ListProjectStatusUpdates_InvalidOwnerType(t *testing.T) { + serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) + + mockedClient := githubv4mock.NewMockedHTTPClient() + client := githubv4.NewClient(mockedClient) + deps := BaseDeps{ + GQLClient: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(map[string]any{ + "owner": "octocat", + "owner_type": "invalid", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, `invalid owner_type "invalid"`) +} From f3b31a6f720fdf0d39a1732696dcebd86405f264 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 13 Feb 2026 17:02:35 +0100 Subject: [PATCH 3/5] Resolve conflicts --- .../create_project_status_update.snap | 56 - .../get_project_status_update.snap | 20 - .../list_project_status_updates.snap | 42 - pkg/github/projects.go | 1170 ----------------- pkg/github/projects_test.go | 1142 +--------------- 5 files changed, 71 insertions(+), 2359 deletions(-) delete mode 100644 pkg/github/__toolsnaps__/create_project_status_update.snap delete mode 100644 pkg/github/__toolsnaps__/get_project_status_update.snap delete mode 100644 pkg/github/__toolsnaps__/list_project_status_updates.snap diff --git a/pkg/github/__toolsnaps__/create_project_status_update.snap b/pkg/github/__toolsnaps__/create_project_status_update.snap deleted file mode 100644 index 8d07bf40e..000000000 --- a/pkg/github/__toolsnaps__/create_project_status_update.snap +++ /dev/null @@ -1,56 +0,0 @@ -{ - "annotations": { - "title": "Create project status update" - }, - "description": "Create a status update for a GitHub project", - "inputSchema": { - "properties": { - "body": { - "description": "The body of the status update (markdown).", - "type": "string" - }, - "owner": { - "description": "The owner (user or organization login). 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" - }, - "start_date": { - "description": "The start date of the status update in YYYY-MM-DD format.", - "type": "string" - }, - "status": { - "description": "The status of the project.", - "enum": [ - "INACTIVE", - "ON_TRACK", - "AT_RISK", - "OFF_TRACK", - "COMPLETE" - ], - "type": "string" - }, - "target_date": { - "description": "The target date of the status update in YYYY-MM-DD format.", - "type": "string" - } - }, - "required": [ - "owner", - "owner_type", - "project_number" - ], - "type": "object" - }, - "name": "create_project_status_update" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_project_status_update.snap b/pkg/github/__toolsnaps__/get_project_status_update.snap deleted file mode 100644 index da8663e38..000000000 --- a/pkg/github/__toolsnaps__/get_project_status_update.snap +++ /dev/null @@ -1,20 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "Get project status update" - }, - "description": "Get a single project status update by ID", - "inputSchema": { - "properties": { - "status_update_id": { - "description": "The node ID of the project status update.", - "type": "string" - } - }, - "required": [ - "status_update_id" - ], - "type": "object" - }, - "name": "get_project_status_update" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_project_status_updates.snap b/pkg/github/__toolsnaps__/list_project_status_updates.snap deleted file mode 100644 index 85fdf4d3d..000000000 --- a/pkg/github/__toolsnaps__/list_project_status_updates.snap +++ /dev/null @@ -1,42 +0,0 @@ -{ - "annotations": { - "readOnlyHint": true, - "title": "List project status updates" - }, - "description": "List status updates for a GitHub project", - "inputSchema": { - "properties": { - "after": { - "description": "Forward pagination cursor from previous pageInfo.nextCursor.", - "type": "string" - }, - "owner": { - "description": "The owner (user or organization login). The name is not case sensitive.", - "type": "string" - }, - "owner_type": { - "description": "Owner type", - "enum": [ - "user", - "org" - ], - "type": "string" - }, - "per_page": { - "description": "Results per page (max 50)", - "type": "number" - }, - "project_number": { - "description": "The project's number.", - "type": "number" - } - }, - "required": [ - "owner", - "owner_type", - "project_number" - ], - "type": "object" - }, - "name": "list_project_status_updates" -} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 56ba434e0..dcb9193ec 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -31,10 +31,6 @@ const ( MaxProjectsPerPage = 50 ) -// FeatureFlagHoldbackConsolidatedProjects is the feature flag that, when enabled, reverts to -// individual project tools instead of the consolidated project tools. -const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_projects" - // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" @@ -139,1172 +135,6 @@ func derefString(s *githubv4.String) string { return string(*s) } -func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_projects", - Description: t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECTS_USER_TITLE", "List projects"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "query": { - Type: "string", - Description: `Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning".`, - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - }, - Required: []string{"owner_type", "owner"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - queryStr, err := OptionalParam[string](args, "query") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - minimalProjects := []MinimalProject{} - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - Query: queryPtr, - } - - if ownerType == "org" { - projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) - } else { - projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list projects", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - for _, project := range projects { - minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) - } - - response := map[string]any{ - "projects": minimalProjects, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project", - Description: t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_USER_TITLE", "Get project"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "project_number": { - Type: "number", - Description: "The project's number", - }, - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - }, - Required: []string{"project_number", "owner_type", "owner"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var project *github.ProjectV2 - - if ownerType == "org" { - project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) - } else { - project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) - } - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil - } - - minimalProject := convertToMinimalProject(project) - r, err := json.Marshal(minimalProject) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_project_fields", - Description: t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECT_FIELDS_USER_TITLE", "List project fields"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - }, - Required: []string{"owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectFields []*github.ProjectV2Field - - opts := &github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - } - - if ownerType == "org" { - projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) - } else { - projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to list project fields", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - response := map[string]any{ - "fields": projectFields, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project_field", - Description: t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_FIELD_USER_TITLE", "Get project field"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "field_id": { - Type: "number", - Description: "The field's id.", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "field_id"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fieldID, err := RequiredBigInt(args, "field_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectField *github.ProjectV2Field - - if ownerType == "org" { - projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) - } else { - projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project field", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project field", resp, body), nil, nil - } - r, err := json.Marshal(projectField) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_project_items", - Description: t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECT_ITEMS_USER_TITLE", "List project items"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "query": { - Type: "string", - Description: `Query string for advanced filtering of project items using GitHub's project filtering syntax.`, - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - "before": { - Type: "string", - Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", - }, - "fields": { - Type: "array", - Description: "Field IDs to include (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - Required: []string{"owner_type", "owner", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - queryStr, err := OptionalParam[string](args, "query") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - fields, err := OptionalBigIntArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := extractPaginationOptionsFromArgs(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } - - opts := &github.ListProjectItemsOptions{ - Fields: fields, - ListProjectsOptions: github.ListProjectsOptions{ - ListProjectsPaginationOptions: pagination, - Query: queryPtr, - }, - } - - if ownerType == "org" { - projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) - } else { - projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectListFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - response := map[string]any{ - "items": projectItems, - "pageInfo": buildPageInfo(resp), - } - - r, err := json.Marshal(response) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project_item", - Description: t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_ITEM_USER_TITLE", "Get project item"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The item's ID.", - }, - "fields": { - Type: "array", - Description: "Specific list of field IDs to include in the response (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included.", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalBigIntArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var projectItem *github.ProjectV2Item - var opts *github.GetProjectItemOptions - - if len(fields) > 0 { - opts = &github.GetProjectItemOptions{ - Fields: fields, - } - } - - if ownerType == "org" { - projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) - } else { - projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get project item", - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - r, err := json.Marshal(projectItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "add_project_item", - Description: t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ADD_PROJECT_ITEM_USER_TITLE", "Add project item"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_type": { - Type: "string", - Description: "The item's type, either issue or pull_request.", - Enum: []any{"issue", "pull_request"}, - }, - "item_id": { - Type: "number", - Description: "The numeric ID of the issue or pull request to add to the project.", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - itemType, err := RequiredParam[string](args, "item_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if itemType != "issue" && itemType != "pull_request" { - return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - newItem := &github.AddProjectItemOptions{ - ID: itemID, - Type: toNewProjectType(itemType), - } - - var resp *github.Response - var addedItem *github.ProjectV2Item - - if ownerType == "org" { - addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) - } else { - addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectAddFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusCreated { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectAddFailedError, resp, body), nil, nil - } - r, err := json.Marshal(addedItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "update_project_item", - Description: t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_UPDATE_PROJECT_ITEM_USER_TITLE", "Update project item"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The unique identifier of the project item. This is not the issue or pull request ID.", - }, - "updated_field": { - Type: "object", - Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - rawUpdatedField, exists := args["updated_field"] - if !exists { - return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil - } - - fieldValue, ok := rawUpdatedField.(map[string]any) - if !ok || fieldValue == nil { - return utils.NewToolResultError("field_value must be an object"), nil, nil - } - - updatePayload, err := buildUpdateProjectItem(fieldValue) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - var updatedItem *github.ProjectV2Item - - if ownerType == "org" { - updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } else { - updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectUpdateFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectUpdateFailedError, resp, body), nil, nil - } - r, err := json.Marshal(updatedItem) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "delete_project_item", - Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_DELETE_PROJECT_ITEM_USER_TITLE", "Delete project item"), - ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "owner": { - Type: "string", - 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.", - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "item_id": { - Type: "number", - Description: "The internal project item ID to delete from the project (not the issue or pull request ID).", - }, - }, - Required: []string{"owner_type", "owner", "project_number", "item_id"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - client, err := deps.GetClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - var resp *github.Response - if ownerType == "org" { - resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) - } else { - resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) - } - - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - ProjectDeleteFailedError, - resp, - err, - ), nil, nil - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusNoContent { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) - } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, ProjectDeleteFailedError, resp, body), nil, nil - } - return utils.NewToolResultText("project item successfully deleted"), nil, nil - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func ListProjectStatusUpdates(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "list_project_status_updates", - Description: t("TOOL_LIST_PROJECT_STATUS_UPDATES_DESCRIPTION", "List status updates for a GitHub project"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_PROJECT_STATUS_UPDATES_USER_TITLE", "List project status updates"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner (user or organization login). The name is not case sensitive.", - }, - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "per_page": { - Type: "number", - Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), - }, - "after": { - Type: "string", - Description: "Forward pagination cursor from previous pageInfo.nextCursor.", - }, - }, - Required: []string{"owner", "owner_type", "project_number"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - gqlClient, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func GetProjectStatusUpdate(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "get_project_status_update", - Description: t("TOOL_GET_PROJECT_STATUS_UPDATE_DESCRIPTION", "Get a single project status update by ID"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_PROJECT_STATUS_UPDATE_USER_TITLE", "Get project status update"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "status_update_id": { - Type: "string", - Description: "The node ID of the project status update.", - }, - }, - Required: []string{"status_update_id"}, - }, - }, - []scopes.Scope{scopes.ReadProject}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - statusUpdateID, err := RequiredParam[string](args, "status_update_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - gqlClient, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - -func CreateProjectStatusUpdate(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( - ToolsetMetadataProjects, - mcp.Tool{ - Name: "create_project_status_update", - Description: t("TOOL_CREATE_PROJECT_STATUS_UPDATE_DESCRIPTION", "Create a status update for a GitHub project"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_CREATE_PROJECT_STATUS_UPDATE_USER_TITLE", "Create project status update"), - ReadOnlyHint: false, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner (user or organization login). The name is not case sensitive.", - }, - "owner_type": { - Type: "string", - Description: "Owner type", - Enum: []any{"user", "org"}, - }, - "project_number": { - Type: "number", - Description: "The project's number.", - }, - "body": { - Type: "string", - Description: "The body of the status update (markdown).", - }, - "status": { - Type: "string", - Description: "The status of the project.", - Enum: []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"}, - }, - "start_date": { - Type: "string", - Description: "The start date of the status update in YYYY-MM-DD format.", - }, - "target_date": { - Type: "string", - Description: "The target date of the status update in YYYY-MM-DD format.", - }, - }, - Required: []string{"owner", "owner_type", "project_number"}, - }, - }, - []scopes.Scope{scopes.Project}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ownerType, err := RequiredParam[string](args, "owner_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - projectNumber, err := RequiredInt(args, "project_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - body, err := OptionalParam[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - status, err := OptionalParam[string](args, "status") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - startDate, err := OptionalParam[string](args, "start_date") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - targetDate, err := OptionalParam[string](args, "target_date") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - gqlClient, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) - }, - ) - tool.FeatureFlagEnable = FeatureFlagHoldbackConsolidatedProjects - return tool -} - // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { tool := NewTool( diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 619933e22..9b0e07292 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -815,862 +815,63 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }) } -func Test_ListProjectStatusUpdates(t *testing.T) { - serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "list_project_status_updates", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "per_page") - assert.Contains(t, schema.Properties, "after") - assert.ElementsMatch(t, schema.Required, []string{"owner", "owner_type", "project_number"}) - - t.Run("success user project", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(50), - "after": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{ - { - "id": "SU_1", - "body": "On track", - "status": "ON_TRACK", - "createdAt": "2026-01-15T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - { - "id": "SU_2", - "body": "At risk", - "status": "AT_RISK", - "createdAt": "2026-01-10T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "cursor1", - "endCursor": "cursor2", - }, - }, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(1), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - updates, ok := response["statusUpdates"].([]any) - require.True(t, ok) - assert.Len(t, updates, 2) - _, hasPageInfo := response["pageInfo"].(map[string]any) - assert.True(t, hasPageInfo) - - // Verify actual content of returned status updates - firstUpdate, ok := updates[0].(map[string]any) - require.True(t, ok) - assert.Equal(t, "SU_1", firstUpdate["id"]) - assert.Equal(t, "On track", firstUpdate["body"]) - assert.Equal(t, "ON_TRACK", firstUpdate["status"]) - assert.Equal(t, "2026-01-01", firstUpdate["start_date"]) - assert.Equal(t, "2026-03-01", firstUpdate["target_date"]) - creator, ok := firstUpdate["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "octocat", creator["login"]) - }) - - t.Run("success org project", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesOrgQuery{}, - map[string]any{ - "owner": githubv4.String("octo-org"), - "projectNumber": githubv4.Int(5), - "first": githubv4.Int(50), - "after": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "organization": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{ - { - "id": "SU_3", - "body": "Off track", - "status": "OFF_TRACK", - "createdAt": "2026-02-01T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-04-01", - "creator": map[string]any{"login": "admin"}, - }, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - }, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(5), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - updates, ok := response["statusUpdates"].([]any) - require.True(t, ok) - assert.Len(t, updates, 1) - }) - - t.Run("success with pagination cursor", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(10), - "after": githubv4.String("cursor_abc"), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{}, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": true, - "startCursor": "cursor_abc", - "endCursor": "cursor_def", - }, - }, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(1), - "per_page": float64(10), - "after": "cursor_abc", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - updates, ok := response["statusUpdates"].([]any) - require.True(t, ok) - assert.Len(t, updates, 0) - pageInfo, ok := response["pageInfo"].(map[string]any) - require.True(t, ok) - assert.Equal(t, true, pageInfo["hasPreviousPage"]) - assert.Equal(t, "cursor_def", pageInfo["nextCursor"]) - assert.Equal(t, "cursor_abc", pageInfo["prevCursor"]) - // Verify old field names are NOT present - _, hasEndCursor := pageInfo["endCursor"] - assert.False(t, hasEndCursor, "should use nextCursor, not endCursor") - _, hasStartCursor := pageInfo["startCursor"] - assert.False(t, hasStartCursor, "should use prevCursor, not startCursor") - }) - - t.Run("per_page exceeding max is capped", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(50), // Should be capped to MaxProjectsPerPage (50) - "after": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{}, - "pageInfo": map[string]any{"hasNextPage": false, "hasPreviousPage": false, "startCursor": "", "endCursor": ""}, - }, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(1), - "per_page": float64(999), // Exceeds max, should be capped - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - }) - - t.Run("graphql error", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(50), - "after": (*githubv4.String)(nil), - }, - githubv4mock.ErrorResponse("something went wrong"), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(1), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "failed to list project status updates") - }) - - t.Run("missing required params", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - - // Missing owner - request := createMCPRequest(map[string]any{ - "owner_type": "user", - "project_number": float64(1), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: owner") - - // Missing owner_type - request = createMCPRequest(map[string]any{ - "owner": "octocat", - "project_number": float64(1), - }) - result, err = handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: owner_type") - - // Missing project_number - request = createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - }) - result, err = handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: project_number") - }) -} - -func Test_GetProjectStatusUpdate(t *testing.T) { - serverTool := GetProjectStatusUpdate(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "get_project_status_update", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "status_update_id") - assert.ElementsMatch(t, schema.Required, []string{"status_update_id"}) - - t.Run("success", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdateNodeQuery{}, - map[string]any{ - "id": githubv4.ID("SU_abc123"), - }, - githubv4mock.DataResponse(map[string]any{ - "node": map[string]any{ - "id": "SU_abc123", - "body": "Making progress", - "status": "ON_TRACK", - "createdAt": "2026-01-15T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "status_update_id": "SU_abc123", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "SU_abc123", response["id"]) - assert.Equal(t, "Making progress", response["body"]) - assert.Equal(t, "ON_TRACK", response["status"]) - assert.Equal(t, "2026-01-01", response["start_date"]) - assert.Equal(t, "2026-03-01", response["target_date"]) - assert.Contains(t, response["created_at"], "2026-01-15") - creator, ok := response["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "octocat", creator["login"]) - }) - - t.Run("graphql error", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdateNodeQuery{}, - map[string]any{ - "id": githubv4.ID("SU_bad"), - }, - githubv4mock.ErrorResponse("node not found"), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "status_update_id": "SU_bad", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "failed to get project status update") - }) - - t.Run("missing status_update_id", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{}) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "missing required parameter: status_update_id") - }) - - t.Run("not a status update node", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdateNodeQuery{}, - map[string]any{ - "id": githubv4.ID("ISSUE_abc123"), - }, - githubv4mock.DataResponse(map[string]any{ - "node": map[string]any{}, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "status_update_id": "ISSUE_abc123", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - assert.Contains(t, getTextResult(t, result).Text, "node is not a ProjectV2StatusUpdate") - }) -} - -func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { - toolDef := ProjectsList(translations.NullTranslationHelper) - - t.Run("success via consolidated tool", func(t *testing.T) { - // REST mock for detectOwnerType (when owner_type is omitted) - restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), - }) - - // GQL mock for listProjectStatusUpdates - gqlMockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(50), - "after": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{ - { - "id": "SU_1", - "body": "On track", - "status": "ON_TRACK", - "createdAt": "2026-01-15T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "", - "endCursor": "", - }, - }, - }, - }, - }), - ), - ) - - gqlClient := githubv4.NewClient(gqlMockedClient) - deps := BaseDeps{ - Client: gh.NewClient(restClient), - GQLClient: gqlClient, - } - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "list_project_status_updates", - "owner": "octocat", - "project_number": float64(1), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - updates, ok := response["statusUpdates"].([]any) - require.True(t, ok) - assert.Len(t, updates, 1) - }) -} - -func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { - toolDef := ProjectsGet(translations.NullTranslationHelper) - - t.Run("success via consolidated tool", func(t *testing.T) { - gqlMockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdateNodeQuery{}, - map[string]any{ - "id": githubv4.ID("SU_abc123"), - }, - githubv4mock.DataResponse(map[string]any{ - "node": map[string]any{ - "id": "SU_abc123", - "body": "On track", - "status": "ON_TRACK", - "createdAt": "2026-01-15T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - }), - ), - ) - - gqlClient := githubv4.NewClient(gqlMockedClient) - deps := BaseDeps{ - GQLClient: gqlClient, - } - handler := toolDef.Handler(deps) - request := createMCPRequest(map[string]any{ - "method": "get_project_status_update", - "owner": "octocat", - "project_number": float64(1), - "status_update_id": "SU_abc123", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "SU_abc123", response["id"]) - assert.Equal(t, "On track", response["body"]) - }) -} - -func Test_CreateProjectStatusUpdate(t *testing.T) { - serverTool := CreateProjectStatusUpdate(translations.NullTranslationHelper) - tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - - assert.Equal(t, "create_project_status_update", tool.Name) - assert.NotEmpty(t, tool.Description) - schema, ok := tool.InputSchema.(*jsonschema.Schema) - require.True(t, ok, "InputSchema should be a *jsonschema.Schema") - assert.Contains(t, schema.Properties, "owner") - assert.Contains(t, schema.Properties, "owner_type") - assert.Contains(t, schema.Properties, "project_number") - assert.Contains(t, schema.Properties, "body") - assert.Contains(t, schema.Properties, "status") - assert.Contains(t, schema.Properties, "start_date") - assert.Contains(t, schema.Properties, "target_date") - assert.ElementsMatch(t, schema.Required, []string{"owner", "owner_type", "project_number"}) - - t.Run("success with all fields", func(t *testing.T) { - bodyStr := githubv4.String("Project is going well") - statusStr := githubv4.String("ON_TRACK") - startDateStr := githubv4.String("2026-01-01") - targetDateStr := githubv4.String("2026-06-30") - - mockedClient := githubv4mock.NewMockedHTTPClient( - // Mock project ID query for user - githubv4mock.NewQueryMatcher( - struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - }{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(2), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "id": "PVT_project2", - }, - }, - }), - ), - // Mock createProjectV2StatusUpdate mutation - githubv4mock.NewMutationMatcher( - struct { - CreateProjectV2StatusUpdate struct { - StatusUpdate statusUpdateNode - } `graphql:"createProjectV2StatusUpdate(input: $input)"` - }{}, - CreateProjectV2StatusUpdateInput{ - ProjectID: githubv4.ID("PVT_project2"), - Body: &bodyStr, - Status: &statusStr, - StartDate: &startDateStr, - TargetDate: &targetDateStr, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "createProjectV2StatusUpdate": map[string]any{ - "statusUpdate": map[string]any{ - "id": "PVTSU_su001", - "body": "Project is going well", - "status": "ON_TRACK", - "createdAt": "2026-02-09T12:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-06-30", - "creator": map[string]any{"login": "octocat"}, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - "body": "Project is going well", - "status": "ON_TRACK", - "start_date": "2026-01-01", - "target_date": "2026-06-30", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "PVTSU_su001", response["id"]) - assert.Equal(t, "Project is going well", response["body"]) - assert.Equal(t, "ON_TRACK", response["status"]) - assert.Equal(t, "2026-01-01", response["start_date"]) - assert.Equal(t, "2026-06-30", response["target_date"]) - creator, ok := response["creator"].(map[string]any) - require.True(t, ok) - assert.Equal(t, "octocat", creator["login"]) - }) - - t.Run("success with minimal fields", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - // Mock project ID query for user - githubv4mock.NewQueryMatcher( - struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - }{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(2), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "id": "PVT_project2", - }, - }, - }), - ), - // Mock createProjectV2StatusUpdate mutation with minimal input - githubv4mock.NewMutationMatcher( - struct { - CreateProjectV2StatusUpdate struct { - StatusUpdate statusUpdateNode - } `graphql:"createProjectV2StatusUpdate(input: $input)"` - }{}, - CreateProjectV2StatusUpdateInput{ - ProjectID: githubv4.ID("PVT_project2"), - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "createProjectV2StatusUpdate": map[string]any{ - "statusUpdate": map[string]any{ - "id": "PVTSU_su002", - "createdAt": "2026-02-09T12:00:00Z", - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - assert.Equal(t, "PVTSU_su002", response["id"]) - }) +func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) - t.Run("invalid status", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - "status": "INVALID_STATUS", + t.Run("success via consolidated tool", func(t *testing.T) { + // REST mock for detectOwnerType (when owner_type is omitted) + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}), }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "invalid status") - }) - t.Run("success org owner_type", func(t *testing.T) { - bodyStr := githubv4.String("Org project update") - statusStr := githubv4.String("ON_TRACK") - - mockedClient := githubv4mock.NewMockedHTTPClient( - // Mock project ID query for org + // GQL mock for listProjectStatusUpdates + gqlMockedClient := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - struct { - Organization struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"organization(login: $owner)"` - }{}, + statusUpdatesUserQuery{}, map[string]any{ - "owner": githubv4.String("octo-org"), - "projectNumber": githubv4.Int(5), + "owner": githubv4.String("octocat"), + "projectNumber": githubv4.Int(1), + "first": githubv4.Int(50), + "after": (*githubv4.String)(nil), }, githubv4mock.DataResponse(map[string]any{ - "organization": map[string]any{ + "user": map[string]any{ "projectV2": map[string]any{ - "id": "PVT_org_project5", - }, - }, - }), - ), - // Mock createProjectV2StatusUpdate mutation - githubv4mock.NewMutationMatcher( - struct { - CreateProjectV2StatusUpdate struct { - StatusUpdate statusUpdateNode - } `graphql:"createProjectV2StatusUpdate(input: $input)"` - }{}, - CreateProjectV2StatusUpdateInput{ - ProjectID: githubv4.ID("PVT_org_project5"), - Body: &bodyStr, - Status: &statusStr, - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "createProjectV2StatusUpdate": map[string]any{ - "statusUpdate": map[string]any{ - "id": "PVTSU_org_su001", - "body": "Org project update", - "status": "ON_TRACK", - "createdAt": "2026-02-09T12:00:00Z", - "creator": map[string]any{"login": "admin"}, + "statusUpdates": map[string]any{ + "nodes": []map[string]any{ + { + "id": "SU_1", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, }, }, }), ), ) - client := githubv4.NewClient(mockedClient) + gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - GQLClient: client, + Client: gh.NewClient(restClient), + GQLClient: gqlClient, } - handler := serverTool.Handler(deps) + handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ - "owner": "octo-org", - "owner_type": "org", - "project_number": float64(5), - "body": "Org project update", - "status": "ON_TRACK", + "method": "list_project_status_updates", + "owner": "octocat", + "project_number": float64(1), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) @@ -1681,146 +882,58 @@ func Test_CreateProjectStatusUpdate(t *testing.T) { var response map[string]any err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - assert.Equal(t, "PVTSU_org_su001", response["id"]) - assert.Equal(t, "Org project update", response["body"]) - assert.Equal(t, "ON_TRACK", response["status"]) + updates, ok := response["statusUpdates"].([]any) + require.True(t, ok) + assert.Len(t, updates, 1) }) +} - t.Run("graphql mutation error", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - // Mock project ID query for user - succeeds +func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + t.Run("success via consolidated tool", func(t *testing.T) { + gqlMockedClient := githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - }{}, + statusUpdateNodeQuery{}, map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(2), + "id": githubv4.ID("SU_abc123"), }, githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "id": "PVT_project2", - }, + "node": map[string]any{ + "id": "SU_abc123", + "body": "On track", + "status": "ON_TRACK", + "createdAt": "2026-01-15T10:00:00Z", + "startDate": "2026-01-01", + "targetDate": "2026-03-01", + "creator": map[string]any{"login": "octocat"}, }, }), ), - // Mock createProjectV2StatusUpdate mutation - fails - githubv4mock.NewMutationMatcher( - struct { - CreateProjectV2StatusUpdate struct { - StatusUpdate statusUpdateNode - } `graphql:"createProjectV2StatusUpdate(input: $input)"` - }{}, - CreateProjectV2StatusUpdateInput{ - ProjectID: githubv4.ID("PVT_project2"), - }, - nil, - githubv4mock.ErrorResponse("mutation failed: insufficient permissions"), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, ProjectStatusUpdateCreateFailedError) - }) - - t.Run("project ID resolution failure", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - // Mock project ID query for user - fails - githubv4mock.NewQueryMatcher( - struct { - User struct { - ProjectV2 struct { - ID githubv4.ID - } `graphql:"projectV2(number: $projectNumber)"` - } `graphql:"user(login: $owner)"` - }{}, - map[string]any{ - "owner": githubv4.String("nonexistent"), - "projectNumber": githubv4.Int(999), - }, - githubv4mock.ErrorResponse("Could not resolve to a User with the login of 'nonexistent'"), - ), ) - client := githubv4.NewClient(mockedClient) + gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - GQLClient: client, + GQLClient: gqlClient, } - handler := serverTool.Handler(deps) + handler := toolDef.Handler(deps) request := createMCPRequest(map[string]any{ - "owner": "nonexistent", - "owner_type": "user", - "project_number": float64(999), + "method": "get_project_status_update", + "owner": "octocat", + "project_number": float64(1), + "status_update_id": "SU_abc123", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, ProjectResolveIDFailedError) - }) - - t.Run("invalid start_date format", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - "start_date": "not-a-date", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.False(t, result.IsError) - require.NoError(t, err) - require.True(t, result.IsError) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "invalid start_date") - assert.Contains(t, textContent.Text, "YYYY-MM-DD") - }) - - t.Run("invalid target_date format", func(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(2), - "target_date": "01/15/2026", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) - require.True(t, result.IsError) - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "invalid target_date") - assert.Contains(t, textContent.Text, "YYYY-MM-DD") + assert.Equal(t, "SU_abc123", response["id"]) + assert.Equal(t, "On track", response["body"]) }) } @@ -1907,116 +1020,3 @@ func Test_ProjectsWrite_CreateProjectStatusUpdate(t *testing.T) { assert.Equal(t, "AT_RISK", response["status"]) }) } - -func Test_ListProjectStatusUpdates_NegativePerPage(t *testing.T) { - serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) - - // With a negative per_page, the handler should clamp to MaxProjectsPerPage (50) - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - statusUpdatesUserQuery{}, - map[string]any{ - "owner": githubv4.String("octocat"), - "projectNumber": githubv4.Int(1), - "first": githubv4.Int(50), // clamped to default - "after": (*githubv4.String)(nil), - }, - githubv4mock.DataResponse(map[string]any{ - "user": map[string]any{ - "projectV2": map[string]any{ - "statusUpdates": map[string]any{ - "nodes": []map[string]any{ - { - "id": "SU_neg", - "body": "Negative per_page test", - "status": "ON_TRACK", - "createdAt": "2026-01-15T10:00:00Z", - "startDate": "2026-01-01", - "targetDate": "2026-03-01", - "creator": map[string]any{"login": "octocat"}, - }, - }, - "pageInfo": map[string]any{ - "hasNextPage": false, - "hasPreviousPage": false, - "startCursor": "cursor1", - "endCursor": "cursor1", - }, - }, - }, - }, - }), - ), - ) - - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "user", - "project_number": float64(1), - "per_page": float64(-5), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - updates, ok := response["statusUpdates"].([]any) - require.True(t, ok) - assert.Len(t, updates, 1) -} - -func Test_CreateProjectStatusUpdate_InvalidOwnerType(t *testing.T) { - serverTool := CreateProjectStatusUpdate(translations.NullTranslationHelper) - - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "invalid", - "project_number": float64(2), - "body": "Test", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, `invalid owner_type "invalid"`) -} - -func Test_ListProjectStatusUpdates_InvalidOwnerType(t *testing.T) { - serverTool := ListProjectStatusUpdates(translations.NullTranslationHelper) - - mockedClient := githubv4mock.NewMockedHTTPClient() - client := githubv4.NewClient(mockedClient) - deps := BaseDeps{ - GQLClient: client, - } - handler := serverTool.Handler(deps) - request := createMCPRequest(map[string]any{ - "owner": "octocat", - "owner_type": "invalid", - "project_number": float64(1), - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - - require.NoError(t, err) - require.True(t, result.IsError) - - textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, `invalid owner_type "invalid"`) -} From 2364bc4fb93346d9a34e6de957f423212a3362c8 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 13 Feb 2026 17:03:28 +0100 Subject: [PATCH 4/5] Update doc --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 008d974aa..6e964a192 100644 --- a/README.md +++ b/README.md @@ -983,9 +983,9 @@ The following sets of tools are available: - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - `method`: The method to execute (string, required) - - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) + - `owner`: The owner (user or organization login). The name is not case sensitive. (string, optional) - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - - `project_number`: The project's number. (number, required) + - `project_number`: The project's number. (number, optional) - `status_update_id`: The node ID of the project status update. Required for 'get_project_status_update' method. (string, optional) - **projects_list** - List GitHub Projects resources From 3e0de4874a4e800407fee28e73c81d427324fbc2 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Fri, 13 Feb 2026 17:05:20 +0100 Subject: [PATCH 5/5] Update aliases --- pkg/github/deprecated_tool_aliases.go | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 5845c55a4..4415731fb 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -30,16 +30,13 @@ var DeprecatedToolAliases = map[string]string{ "delete_workflow_run_logs": "actions_run_trigger", // Projects tools consolidated - "list_projects": "projects_list", - "list_project_fields": "projects_list", - "list_project_items": "projects_list", - "get_project": "projects_get", - "get_project_field": "projects_get", - "get_project_item": "projects_get", - "add_project_item": "projects_write", - "update_project_item": "projects_write", - "delete_project_item": "projects_write", - "list_project_status_updates": "projects_list", - "get_project_status_update": "projects_get", - "create_project_status_update": "projects_write", + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", }