From ad3fcf5fb9f272341b6b66087a02ea9501e62bf4 Mon Sep 17 00:00:00 2001 From: e-straight Date: Mon, 9 Feb 2026 18:41:13 -0800 Subject: [PATCH] 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 | 636 +++++++++- pkg/github/projects_test.go | 1093 +++++++++++++++++ pkg/github/tools.go | 3 + pkg/github/toolset_instructions.go | 2 + 14 files changed, 1853 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 c6a0ea849..9f136ec7d 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 4fed6364f..a3fbbc5d1 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" @@ -20,11 +21,15 @@ 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 @@ -33,17 +38,101 @@ const FeatureFlagHoldbackConsolidatedProjects = "mcp_holdback_consolidated_proje // 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, @@ -1025,6 +1114,191 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo 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( @@ -1049,6 +1323,7 @@ Use this tool to list projects for a user or organization, or list project field projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, + projectsMethodListProjectStatusUpdates, }, }, "owner_type": { @@ -1062,7 +1337,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", @@ -1116,8 +1391,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 { @@ -1128,22 +1403,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 } }, ) @@ -1174,6 +1448,7 @@ Use this tool to get details about individual projects, project fields, and proj projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, + projectsMethodGetProjectStatusUpdate, }, }, "owner_type": { @@ -1204,6 +1479,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"}, }, @@ -1215,6 +1494,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 @@ -1277,7 +1569,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, @@ -1293,6 +1585,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, + projectsMethodCreateProjectStatusUpdate, }, }, "owner_type": { @@ -1337,6 +1630,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"}, }, @@ -1433,6 +1743,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 } @@ -1864,6 +2192,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" { @@ -1891,41 +2256,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 @@ -1952,6 +2286,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 24163ef90..627a31c28 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -2330,3 +2330,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/tools.go b/pkg/github/tools.go index 676976140..5fff954d8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -281,6 +281,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetProjectField(t), ListProjectItems(t), GetProjectItem(t), + ListProjectStatusUpdates(t), + GetProjectStatusUpdate(t), + CreateProjectStatusUpdate(t), AddProjectItem(t), DeleteProjectItem(t), UpdateProjectItem(t), 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.