diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8aa3024f..bd6265e6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -26,3 +26,8 @@ updates: - "xunit.*" - "coverlet" - "coverlet.*" + cooldown: + default-days: 8 + semver-major-days: 15 + semver-minor-days: 10 + semver-patch-days: 8 diff --git a/docs/TOOLSET.md b/docs/TOOLSET.md index d429d6d2..d5e050f6 100644 --- a/docs/TOOLSET.md +++ b/docs/TOOLSET.md @@ -2,15 +2,6 @@ This page lists all available tools provided by the local Azure DevOps MCP server. Use it as a reference to understand what each tool does, what parameters it requires, and how tools are organized by functional area. -## Overview - -### Advanced Security - -| Tool | Description | -| --------------------------------------------------------------------- | -------------------------------------------------------- | -| [mcp_ado_advsec_get_alerts](#mcp_ado_advsec_get_alerts) | Retrieve Advanced Security alerts for a repository | -| [mcp_ado_advsec_get_alert_details](#mcp_ado_advsec_get_alert_details) | Get detailed information about a specific security alert | - ### Core | Tool | Description | @@ -19,6 +10,80 @@ This page lists all available tools provided by the local Azure DevOps MCP serve | [mcp_ado_core_list_project_teams](#mcp_ado_core_list_project_teams) | List teams within a project | | [mcp_ado_core_get_identity_ids](#mcp_ado_core_get_identity_ids) | Retrieve identity IDs by search filter | +### Work + +> **Note:** The work tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#work) tool structure. + +| Tool | Action | Description | +| ----------------------------- | -------------------------- | --------------------------------------------------------------------------------------- | +| [work](#work) | `list_iterations` | List all iterations in a project | +| [work](#work) | `list_team_iterations` | List iterations assigned to a team | +| [work](#work) | `get_team_settings` | Get team settings including default iteration, backlog iteration, and default area path | +| [work](#work) | `get_team_capacity` | Get team capacity for an iteration | +| [work](#work) | `get_iteration_capacities` | Get an iteration's capacity for all teams in the iteration and project | +| [work_iteration_write](#work) | `create` | Create iterations | +| [work_iteration_write](#work) | `assign` | Assign iterations to a team | +| [work_capacity_write](#work) | `update` | Update the team capacity of a team member for a specific iteration | + +### Work Items + +> **Note:** The work item tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#work-items) tool structure. + +| Tool | Action | Description | +| ----------------------------------------------------------- | ---------------------- | ----------------------------------------------------------------------- | +| [wit_work_item](#wit_work_item) | `get` | Get a single work item by ID | +| [wit_work_item](#wit_work_item) | `get_batch` | Retrieve multiple work items by IDs | +| [wit_work_item](#wit_work_item) | `list_comments` | List comments on a work item | +| [wit_work_item](#wit_work_item) | `my` | List work items relevant to the authenticated user | +| [wit_work_item](#wit_work_item) | `list_revisions` | Get revision history of a work item | +| [wit_work_item](#wit_work_item) | `list_for_iteration` | Get work items in a specific team iteration | +| [wit_work_item](#wit_work_item) | `get_type` | Get metadata for a work item type | +| [wit_work_item_write](#wit_work_item_write) | `create` | Create a new work item | +| [wit_work_item_write](#wit_work_item_write) | `update` | Update fields on a single work item | +| [wit_work_item_write](#wit_work_item_write) | `update_batch` | Update multiple work items in one call | +| [wit_work_item_write](#wit_work_item_write) | `add_child` | Create child work items under a parent | +| [wit_work_item_comment_write](#wit_work_item_comment_write) | `add` | Add a comment to a work item | +| [wit_work_item_comment_write](#wit_work_item_comment_write) | `update` | Update an existing comment on a work item | +| [wit_work_item_link_write](#wit_work_item_link_write) | `link` | Link two work items together | +| [wit_work_item_link_write](#wit_work_item_link_write) | `unlink` | Remove links from a work item | +| [wit_work_item_link_write](#wit_work_item_link_write) | `link_to_pull_request` | Link a work item to a pull request | +| [wit_work_item_link_write](#wit_work_item_link_write) | `add_artifact_link` | Add a repository, branch, commit, or build artifact link to a work item | +| [wit_query](#wit_query) | `get` | Get a work item query by ID or path | +| [wit_query](#wit_query) | `get_results` | Execute a saved query and return results | +| [wit_query](#wit_query) | `wiql` | Execute an ad-hoc WIQL query | +| [wit_backlog](#wit_backlog) | `list` | List backlog levels for a team | +| [wit_backlog](#wit_backlog) | `list_work_items` | Get work items in a specific backlog level | +| [wit_backlog](#wit_backlog) | `reorder` | Reorder work items in a backlog or iteration | +| [wit_work_item_attachment](#wit_work_item_attachment) | | Download a work item attachment; save locally or return as base64 | + +### Repositories + +> **Note:** The repository tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#repos) tool structure. + +| Tool | Action | Description | +| ----------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------- | +| [repo_repository](#repo_repository) | `get` | Get a repository by name or ID | +| [repo_repository](#repo_repository) | `list` | List repositories in a project | +| [repo_pull_request](#repo_pull_request) | `get` | Get a pull request by ID | +| [repo_pull_request](#repo_pull_request) | `list` | List pull requests in a repository or project | +| [repo_pull_request](#repo_pull_request) | `list_by_commits` | Find pull requests that contain specific commit IDs | +| [repo_pull_request_thread](#repo_pull_request_thread) | `list` | List comment threads on a pull request | +| [repo_pull_request_thread](#repo_pull_request_thread) | `list_comments` | List comments in a specific thread | +| [repo_branch](#repo_branch) | `get` | Get a branch by name | +| [repo_branch](#repo_branch) | `list` | List branches in a repository | +| [repo_branch](#repo_branch) | `list_mine` | List branches the current user has pushed to | +| [repo_file](#repo_file) | `get_content` | Get the text content of a file at a specific branch, tag, or commit | +| [repo_file](#repo_file) | `list_directory` | List files and folders in a directory | +| [repo_search_commits](#repo_search_commits) | | Search commits with filtering by text, author, date range, and more | +| [repo_pull_request_write](#repo_pull_request_write) | `create` | Create a pull request | +| [repo_pull_request_write](#repo_pull_request_write) | `update` | Update a pull request, including setting autocomplete | +| [repo_pull_request_write](#repo_pull_request_write) | `update_reviewers` | Add or remove pull request reviewers | +| [repo_pull_request_write](#repo_pull_request_write) | `vote` | Cast a vote on a pull request | +| [repo_pull_request_thread_write](#repo_pull_request_thread_write) | `create` | Create a new comment thread on a pull request | +| [repo_pull_request_thread_write](#repo_pull_request_thread_write) | `reply` | Reply to a comment in a thread | +| [repo_pull_request_thread_write](#repo_pull_request_thread_write) | `update_status` | Update the status of a comment thread | +| [repo_create_branch](#repo_create_branch) | | Create a branch | + ### Pipelines > **Note:** The pipeline tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#pipelines) tool structure. @@ -40,54 +105,21 @@ This page lists all available tools provided by the local Azure DevOps MCP serve | [pipelines_write](#pipelines_write) | `create_pipeline` | Create a new YAML pipeline definition | | [pipelines_write](#pipelines_write) | `update_build_stage` | Cancel, retry, or run a stage on an in-flight build | -### Repositories - -| Tool | Description | -| --------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | -| [mcp_ado_repo_list_repos_by_project](#mcp_ado_repo_list_repos_by_project) | List all repositories in a project | -| [mcp_ado_repo_get_repo_by_name_or_id](#mcp_ado_repo_get_repo_by_name_or_id) | Get repository details by name or ID | -| [mcp_ado_repo_list_branches_by_repo](#mcp_ado_repo_list_branches_by_repo) | List all branches in a repository | -| [mcp_ado_repo_list_my_branches_by_repo](#mcp_ado_repo_list_my_branches_by_repo) | List branches created by current user | -| [mcp_ado_repo_get_branch_by_name](#mcp_ado_repo_get_branch_by_name) | Get details of a specific branch | -| [mcp_ado_repo_create_branch](#mcp_ado_repo_create_branch) | Create a new branch from a source branch | -| [mcp_ado_repo_search_commits](#mcp_ado_repo_search_commits) | Search for commits with comprehensive filters | -| [mcp_ado_repo_list_pull_requests_by_repo_or_project](#mcp_ado_repo_list_pull_requests_by_repo_or_project) | List pull requests with optional filters | -| [mcp_ado_repo_list_pull_requests_by_commits](#mcp_ado_repo_list_pull_requests_by_commits) | Find pull requests containing specific commits | -| [mcp_ado_repo_get_pull_request_by_id](#mcp_ado_repo_get_pull_request_by_id) | Get details of a specific pull request | -| [mcp_ado_repo_get_pull_request_changes](#mcp_ado_repo_get_pull_request_changes) | Get file changes (diff) for a pull request | -| [mcp_ado_repo_create_pull_request](#mcp_ado_repo_create_pull_request) | Create a new pull request | -| [mcp_ado_repo_update_pull_request](#mcp_ado_repo_update_pull_request) | Update pull request properties and settings | -| [mcp_ado_repo_update_pull_request_reviewers](#mcp_ado_repo_update_pull_request_reviewers) | Add or remove reviewers from a pull request | -| [mcp_ado_repo_vote_pull_request](#mcp_ado_repo_vote_pull_request) | Cast a vote on a pull request | -| [mcp_ado_repo_list_pull_request_threads](#mcp_ado_repo_list_pull_request_threads) | List comment threads on a pull request | -| [mcp_ado_repo_list_pull_request_thread_comments](#mcp_ado_repo_list_pull_request_thread_comments) | List comments in a specific thread | -| [mcp_ado_repo_create_pull_request_thread](#mcp_ado_repo_create_pull_request_thread) | Create a new comment thread on a pull request | -| [mcp_ado_repo_update_pull_request_thread](#mcp_ado_repo_update_pull_request_thread) | Update an existing pull request comment thread | -| [mcp_ado_repo_reply_to_comment](#mcp_ado_repo_reply_to_comment) | Reply to a pull request comment | -| [mcp_ado_repo_list_directory](#mcp_ado_repo_list_directory) | List files and folders in a directory | -| [mcp_ado_repo_get_file_content](#mcp_ado_repo_get_file_content) | Get file content at a specific version | - -### Search - -| Tool | Description | -| --------------------------------------------------- | ------------------------------------- | -| [mcp_ado_search_code](#mcp_ado_search_code) | Search for code across repositories | -| [mcp_ado_search_wiki](#mcp_ado_search_wiki) | Search wiki pages by keywords | -| [mcp_ado_search_workitem](#mcp_ado_search_workitem) | Search work items by text and filters | - ### Test Plans -| Tool | Description | -| ----------------------------------------------------------------------------------------------------- | -------------------------------------- | -| [mcp_ado_testplan_list_test_plans](#mcp_ado_testplan_list_test_plans) | List test plans in a project | -| [mcp_ado_testplan_create_test_plan](#mcp_ado_testplan_create_test_plan) | Create a new test plan | -| [mcp_ado_testplan_list_test_suites](#mcp_ado_testplan_list_test_suites) | List test suites in a test plan | -| [mcp_ado_testplan_create_test_suite](#mcp_ado_testplan_create_test_suite) | Create a test suite within a test plan | -| [mcp_ado_testplan_add_test_cases_to_suite](#mcp_ado_testplan_add_test_cases_to_suite) | Add test cases to a test suite | -| [mcp_ado_testplan_list_test_cases](#mcp_ado_testplan_list_test_cases) | List test cases in a test suite | -| [mcp_ado_testplan_create_test_case](#mcp_ado_testplan_create_test_case) | Create a new test case work item | -| [mcp_ado_testplan_update_test_case_steps](#mcp_ado_testplan_update_test_case_steps) | Update steps of an existing test case | -| [mcp_ado_testplan_show_test_results_from_build_id](#mcp_ado_testplan_show_test_results_from_build_id) | Get test results for a specific build | +> **Note:** The test plan tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#test-plans) tool structure. + +| Tool | Action | Description | +| ------------------------------------------------------------------------------------- | ---------------- | -------------------------------------- | +| [testplan](#testplan) | `list_plans` | List test plans in a project | +| [testplan](#testplan) | `list_suites` | List test suites under a test plan | +| [testplan](#testplan) | `list_cases` | List test cases under a test suite | +| [testplan_show_test_results_from_build_id](#testplan_show_test_results_from_build_id) | | Get test results for a specific build | +| [testplan_test_plan_write](#testplan_test_plan_write) | `create` | Create a new test plan | +| [testplan_test_suite_write](#testplan_test_suite_write) | `create` | Create a test suite within a test plan | +| [testplan_test_suite_write](#testplan_test_suite_write) | `add_test_cases` | Add test cases to a test suite | +| [testplan_test_case_write](#testplan_test_case_write) | `create` | Create a new test case work item | +| [testplan_test_case_write](#testplan_test_case_write) | `update_steps` | Update steps of an existing test case | ### Wiki @@ -102,598 +134,17 @@ This page lists all available tools provided by the local Azure DevOps MCP serve | [wiki](#wiki) | `get_page_content` | Retrieve wiki page content | | [wiki_upsert_page](#wiki) | | Create or update a wiki page | -### Work Items - -| Tool | Description | -| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| [mcp_ado_wit_get_work_item](#mcp_ado_wit_get_work_item) | Get a work item by ID | -| [mcp_ado_wit_get_work_items_batch_by_ids](#mcp_ado_wit_get_work_items_batch_by_ids) | Retrieve multiple work items by IDs | -| [mcp_ado_wit_create_work_item](#mcp_ado_wit_create_work_item) | Create a new work item | -| [mcp_ado_wit_update_work_item](#mcp_ado_wit_update_work_item) | Update fields of a work item | -| [mcp_ado_wit_update_work_items_batch](#mcp_ado_wit_update_work_items_batch) | Update multiple work items in batch | -| [mcp_ado_wit_add_child_work_items](#mcp_ado_wit_add_child_work_items) | Create child work items under a parent | -| [mcp_ado_wit_work_items_link](#mcp_ado_wit_work_items_link) | Link work items together | -| [mcp_ado_wit_work_item_unlink](#mcp_ado_wit_work_item_unlink) | Remove links from a work item | -| [mcp_ado_wit_add_artifact_link](#mcp_ado_wit_add_artifact_link) | Link artifacts (commits, builds, PRs) to work items | -| [mcp_ado_wit_link_work_item_to_pull_request](#mcp_ado_wit_link_work_item_to_pull_request) | Link a work item to a pull request | -| [mcp_ado_wit_list_work_item_comments](#mcp_ado_wit_list_work_item_comments) | List comments on a work item | -| [mcp_ado_wit_add_work_item_comment](#mcp_ado_wit_add_work_item_comment) | Add a comment to a work item | -| [mcp_ado_wit_update_work_item_comment](#mcp_ado_wit_update_work_item_comment) | Update an existing comment on a work item | -| [mcp_ado_wit_list_work_item_revisions](#mcp_ado_wit_list_work_item_revisions) | Get revision history of a work item | -| [mcp_ado_wit_get_work_item_type](#mcp_ado_wit_get_work_item_type) | Get details of a work item type | -| [mcp_ado_wit_my_work_items](#mcp_ado_wit_my_work_items) | List work items relevant to current user | -| [mcp_ado_wit_get_work_items_for_iteration](#mcp_ado_wit_get_work_items_for_iteration) | Get work items in a specific iteration | -| [mcp_ado_wit_list_backlogs](#mcp_ado_wit_list_backlogs) | List backlogs for a team | -| [mcp_ado_wit_list_backlog_work_items](#mcp_ado_wit_list_backlog_work_items) | Get work items in a backlog | -| [mcp_ado_wit_get_query](#mcp_ado_wit_get_query) | Get a work item query by ID or path | -| [mcp_ado_wit_get_query_results_by_id](#mcp_ado_wit_get_query_results_by_id) | Execute a query and get results | -| [mcp_ado_wit_query_by_wiql](#mcp_ado_wit_query_by_wiql) | Execute a WIQL query and return matching work items | -| [mcp_ado_wit_get_work_item_attachment](#mcp_ado_wit_get_work_item_attachment) | Download a work item attachment; save locally or return as base64 | - -### Work - -> **Note:** The work tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#work) tool structure. - -| Tool | Action | Description | -| ----------------------------- | -------------------------- | --------------------------------------------------------------------------------------- | -| [work](#work) | `list_iterations` | List all iterations in a project | -| [work](#work) | `list_team_iterations` | List iterations assigned to a team | -| [work](#work) | `get_team_settings` | Get team settings including default iteration, backlog iteration, and default area path | -| [work](#work) | `get_team_capacity` | Get team capacity for an iteration | -| [work](#work) | `get_iteration_capacities` | Get an iteration's capacity for all teams in the iteration and project | -| [work_iteration_write](#work) | `create` | Create iterations | -| [work_iteration_write](#work) | `assign` | Assign iterations to a team | -| [work_capacity_write](#work) | `update` | Update the team capacity of a team member for a specific iteration | - -## Details - -### Advanced Security - -#### mcp_ado_advsec_get_alerts - -Retrieve Advanced Security alerts for a repository. - -- **Required**: `project`, `repository`, `confidenceLevels` -- **Optional**: `alertType`, `continuationToken`, `onlyDefaultBranch`, `orderBy`, `ref`, `ruleId`, `ruleName`, `severities`, `states`, `toolName`, `top`, `validity` - -#### mcp_ado_advsec_get_alert_details - -Get detailed information about a specific Advanced Security alert. - -- **Required**: `project`, `repository`, `alertId` -- **Optional**: `ref` - -### Core - -#### mcp_ado_core_list_projects - -Retrieve a list of projects in your Azure DevOps organization. - -- **Required**: None -- **Optional**: `continuationToken`, `projectNameFilter`, `skip`, `stateFilter`, `top` - -#### mcp_ado_core_list_project_teams - -Retrieve a list of teams for the specified Azure DevOps project. - -- **Required**: `project` -- **Optional**: `mine`, `skip`, `top` - -#### mcp_ado_core_get_identity_ids - -Retrieve Azure DevOps identity IDs for a provided search filter. - -- **Required**: `searchFilter` -- **Optional**: None - -### Pipelines - -> **Note:** The pipeline tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#pipelines) tool structure. - -The pipeline tools are consolidated into grouped dispatchers using an `action` parameter. - -#### pipelines_build - -Retrieve build data for a project. - -| Action | Required params | Optional params | -| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list` | `project` | `branchName`, `buildIds`, `buildNumber`, `continuationToken`, `definitions`, `deletedFilter`, `maxBuildsPerDefinition`, `maxTime`, `minTime`, `properties`, `queryOrder`, `queues`, `reasonFilter`, `repositoryId`, `repositoryType`, `requestedFor`, `resultFilter`, `statusFilter`, `tagFilters`, `top` | -| `get_status` | `project`, `buildId` | None | -| `get_changes` | `project`, `buildId` | `continuationToken`, `includeSourceChange`, `top` | - -#### pipelines_build_log - -Retrieve build log data for a project. - -| Action | Required params | Optional params | -| ------------- | ----------------------------- | ---------------------- | -| `list` | `project`, `buildId` | None | -| `get_content` | `project`, `buildId`, `logId` | `startLine`, `endLine` | - -#### pipelines_definition - -Retrieve pipeline definition data for a project. - -| Action | Required params | Optional params | -| ---------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list` | `project` | `builtAfter`, `continuationToken`, `definitionIds`, `includeAllProperties`, `includeLatestBuilds`, `minMetricsTime`, `name`, `notBuiltAfter`, `path`, `processType`, `queryOrder`, `repositoryId`, `repositoryType`, `taskIdFilter`, `top`, `yamlFilename` | -| `list_revisions` | `project`, `definitionId` | None | - -#### pipelines_run - -Retrieve pipeline run data for a project. - -| Action | Required params | Optional params | -| ------ | -------------------------------- | --------------- | -| `get` | `project`, `pipelineId`, `runId` | None | -| `list` | `project`, `pipelineId` | None | - -#### pipelines_artifact - -Retrieve and download build artifacts. - -| Action | Required params | Optional params | -| ---------- | ------------------------------------ | ------------------------------------------------------------------------------- | -| `list` | `project`, `buildId` | None | -| `download` | `project`, `buildId`, `artifactName` | `destinationPath` (relative path; absolute paths and traversal are not allowed) | - -#### pipelines_write - -Write operations for pipelines and builds. - -| Action | Required params | Optional params | -| -------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `run_pipeline` | `project`, `pipelineId` | `pipelineVersion`, `previewRun`, `resources`, `stagesToSkip`, `templateParameters`, `variables`, `yamlOverride` | -| `create_pipeline` | `project`, `name`, `yamlPath`, `repositoryType`, `repositoryName` | `folder`, `repositoryConnectionId`, `repositoryId` | -| `update_build_stage` | `project`, `buildId`, `stageName`, `status` | `forceRetryAllJobs` | - -### Repositories - -#### mcp_ado_repo_list_repos_by_project - -Retrieve a list of repositories for a given project. - -- **Required**: `project` -- **Optional**: `repoNameFilter`, `skip`, `top` - -#### mcp_ado_repo_get_repo_by_name_or_id - -Get the repository by project and repository name or ID. - -- **Required**: `project`, `repositoryNameOrId` -- **Optional**: None - -#### mcp_ado_repo_list_branches_by_repo - -Retrieve a list of branches for a given repository. - -- **Required**: `repositoryId` -- **Optional**: `filterContains`, `top` - -#### mcp_ado_repo_list_my_branches_by_repo - -Retrieve a list of my branches for a given repository Id. - -- **Required**: `repositoryId` -- **Optional**: `filterContains`, `top` - -#### mcp_ado_repo_get_branch_by_name - -Get a branch by its name. - -- **Required**: `repositoryId`, `branchName` -- **Optional**: None - -#### mcp_ado_repo_create_branch - -Create a new branch in the repository. - -- **Required**: `repositoryId`, `branchName` -- **Optional**: `sourceBranchName`, `sourceCommitId` - -#### mcp_ado_repo_search_commits - -Search for commits across projects and repositories with comprehensive filtering capabilities. - -- **Required**: `searchText` -- **Optional**: `project`, `repository`, `branch`, `author`, `commitStartDate`, `commitEndDate`, `orderBy`, `includeFacets`, `skip`, `top` - -#### mcp_ado_repo_list_pull_requests_by_repo_or_project - -Retrieve a list of pull requests for a given repository. - -- **Required**: None (either `repositoryId` or `project` must be provided) -- **Optional**: `created_by_me`, `created_by_user`, `i_am_reviewer`, `project`, `repositoryId`, `skip`, `sourceRefName`, `status`, `targetRefName`, `top`, `user_is_reviewer` - -#### mcp_ado_repo_list_pull_requests_by_commits - -Lists pull requests by commit IDs to find which pull requests contain specific commits. - -- **Required**: `project`, `repository`, `commits` -- **Optional**: `queryType` - -#### mcp_ado_repo_get_pull_request_by_id - -Get a pull request by its ID. - -- **Required**: `repositoryId`, `pullRequestId` -- **Optional**: `project`, `includeWorkItemRefs`, `includeLabels`, `includeChangedFiles` - -#### mcp_ado_repo_get_pull_request_changes - -Get the file changes (diff) for a pull request iteration with actual code diff content. Returns the code changes including line-by-line diffs made in the pull request. - -- **Required**: `repositoryId`, `pullRequestId` -- **Optional**: `iterationId`, `project`, `top`, `skip`, `compareTo`, `includeDiffs`, `includeLineContent` - -**Notes**: - -- If `iterationId` is not specified, returns changes for the latest iteration -- Use `compareTo` to get changes between two specific iterations -- Supports pagination with `top` and `skip` parameters -- By default, includes line-by-line diff metadata (line numbers, change types) AND actual code content -- Set `includeDiffs=false` to get only file metadata without diff information -- Set `includeLineContent=false` to exclude actual code lines and get only diff metadata (line numbers, change types) -- The diff content includes `lineDiffBlocks` showing line numbers and change types -- By default, each `lineDiffBlock` includes `originalLines` (from base) and `modifiedLines` (from target) arrays with actual code content - -#### mcp_ado_repo_create_pull_request - -Create a new pull request. - -- **Required**: `repositoryId`, `sourceRefName`, `targetRefName`, `title` -- **Optional**: `description`, `forkSourceRepositoryId`, `isDraft`, `labels`, `workItems` - -#### mcp_ado_repo_update_pull_request - -Update a Pull Request by ID with specified fields. - -- **Required**: `repositoryId`, `pullRequestId` -- **Optional**: `autoComplete`, `bypassReason`, `deleteSourceBranch`, `description`, `isDraft`, `mergeStrategy`, `status`, `targetRefName`, `title`, `transitionWorkItems` - -#### mcp_ado_repo_update_pull_request_reviewers - -Add or remove reviewers for an existing pull request. - -- **Required**: `repositoryId`, `pullRequestId`, `reviewerIds`, `action` -- **Optional**: None - -#### mcp_ado_repo_vote_pull_request - -Cast a vote on a pull request. - -- **Required**: `repositoryId`, `pullRequestId`, `vote` -- **Optional**: None - -#### mcp_ado_repo_list_pull_request_threads - -Retrieve a list of comment threads for a pull request. - -- **Required**: `repositoryId`, `pullRequestId` -- **Optional**: `baseIteration`, `fullResponse`, `iteration`, `project`, `skip`, `top` - -#### mcp_ado_repo_list_pull_request_thread_comments - -Retrieve a list of comments in a pull request thread. - -- **Required**: `repositoryId`, `pullRequestId`, `threadId` -- **Optional**: `fullResponse`, `project`, `skip`, `top` - -#### mcp_ado_repo_create_pull_request_thread - -Creates a new comment thread on a pull request. - -- **Required**: `repositoryId`, `pullRequestId`, `content` -- **Optional**: `filePath`, `project`, `rightFileEndLine`, `rightFileEndOffset`, `rightFileStartLine`, `rightFileStartOffset`, `status` - -#### mcp_ado_repo_update_pull_request_thread - -Updates an existing comment thread on a pull request. - -- **Required**: `repositoryId`, `pullRequestId`, `threadId` -- **Optional**: `project`, `status` - -#### mcp_ado_repo_reply_to_comment - -Replies to a specific comment on a pull request. - -- **Required**: `repositoryId`, `pullRequestId`, `threadId`, `content` -- **Optional**: `fullResponse`, `project` - -#### mcp_ado_repo_list_directory - -List files and folders in a directory within a repository. - -- **Required**: `repositoryId` -- **Optional**: `path`, `project`, `version`, `versionType`, `recursive`, `recursionDepth` - -#### mcp_ado_repo_get_file_content - -Get the content of a file from a Git repository at a specific version (branch, tag, or commit SHA). - -- **Required**: `repositoryId`, `path` -- **Optional**: `project`, `version`, `versionType` - ### Search -#### mcp_ado_search_code - -Search Azure DevOps Repositories for a given search text. - -- **Required**: `searchText` -- **Optional**: `branch`, `includeFacets`, `path`, `project`, `repository`, `skip`, `top` - -#### mcp_ado_search_wiki - -Search Azure DevOps Wiki for a given search text. - -- **Required**: `searchText` -- **Optional**: `includeFacets`, `project`, `skip`, `top`, `wiki` - -#### mcp_ado_search_workitem - -Get Azure DevOps Work Item search results for a given search text. - -- **Required**: `searchText` -- **Optional**: `areaPath`, `assignedTo`, `includeFacets`, `project`, `skip`, `state`, `top`, `workItemType` - -### Test Plans - -#### mcp_ado_testplan_list_test_plans - -Retrieve a paginated list of test plans from an Azure DevOps project. - -- **Required**: `project` -- **Optional**: `continuationToken`, `filterActivePlans`, `includePlanDetails` - -#### mcp_ado_testplan_create_test_plan - -Creates a new test plan in the project. - -- **Required**: `project`, `name`, `iteration` -- **Optional**: `areaPath`, `description`, `endDate`, `startDate` - -#### mcp_ado_testplan_list_test_suites - -Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id. Returns test suites in a properly nested hierarchical structure. - -- **Required**: `project`, `planId` -- **Optional**: `continuationToken` - -#### mcp_ado_testplan_create_test_suite - -Creates a new test suite in a test plan. - -- **Required**: `project`, `planId`, `parentSuiteId`, `name` -- **Optional**: None - -#### mcp_ado_testplan_add_test_cases_to_suite - -Adds existing test cases to a test suite. - -- **Required**: `project`, `planId`, `suiteId`, `testCaseIds` -- **Optional**: None - -#### mcp_ado_testplan_list_test_cases - -Gets a list of test cases in the test plan. - -- **Required**: `project`, `planid`, `suiteid` -- **Optional**: `continuationToken` - -#### mcp_ado_testplan_create_test_case - -Creates a new test case work item. Step content supports text formatting — use HTML tags (``, ``, ``, ``) or Markdown markers (`**bold**`, `*italic*`, `` `code` ``, `[label](url)`) directly in step text and expected results. - -- **Required**: `project`, `title` -- **Optional**: `areaPath`, `iterationPath`, `priority`, `steps`, `testsWorkItemId` - -#### mcp_ado_testplan_update_test_case_steps - -Update the steps of an existing test case work item. Step content supports text formatting — use HTML tags (``, ``, ``, ``) or Markdown markers (`**bold**`, `*italic*`, `` `code` ``, `[label](url)`) directly in step text and expected results. - -- **Required**: `id`, `steps` -- **Optional**: None - -#### mcp_ado_testplan_show_test_results_from_build_id - -Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. - -- **Required**: `project`, `buildid` -- **Optional**: `outcomes` - -### Wiki - -> **Note:** The wiki tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#wiki) tool structure. - -The wiki tools are consolidated into grouped dispatchers using an `action` parameter. - -| Tool | Action | Description | Read-only | -| ------------------ | ------------------ | -------------------------------------------- | :-------: | -| `wiki` | `list_wikis` | List all wikis in an organization or project | ✅ | -| `wiki` | `get_wiki` | Get details of a specific wiki | ✅ | -| `wiki` | `list_pages` | List pages in a wiki | ✅ | -| `wiki` | `get_page` | Get wiki page metadata (without content) | ✅ | -| `wiki` | `get_page_content` | Retrieve wiki page content | ✅ | -| `wiki_upsert_page` | | Create or update a wiki page | ❌ | - -### Work Items - -#### mcp_ado_wit_get_work_item - -Get a single work item by ID. - -- **Required**: `id`, `project` -- **Optional**: `asOf`, `expand`, `fields` - -#### mcp_ado_wit_get_work_items_batch_by_ids - -Retrieve list of work items by IDs in batch. - -- **Required**: `project`, `ids` -- **Optional**: `fields` - -#### mcp_ado_wit_create_work_item - -Create a new work item in a specified project and work item type. - -- **Required**: `project`, `workItemType`, `fields` -- **Optional**: None - -#### mcp_ado_wit_update_work_item - -Update a work item by ID with specified fields. - -- **Required**: `id`, `updates` -- **Optional**: None - -#### mcp_ado_wit_update_work_items_batch - -Update work items in batch. - -- **Required**: `updates` -- **Optional**: None - -#### mcp_ado_wit_add_child_work_items - -Create one or many child work items from a parent by work item type and parent id. - -- **Required**: `parentId`, `project`, `workItemType`, `items` -- **Optional**: None - -#### mcp_ado_wit_work_items_link - -Link work items together in batch. - -- **Required**: `project`, `updates` -- **Optional**: None - -#### mcp_ado_wit_work_item_unlink - -Remove one or many links from a single work item. - -- **Required**: `project`, `id` -- **Optional**: `type`, `url` - -#### mcp_ado_wit_add_artifact_link - -Add artifact links (repository, branch, commit, builds) to work items. - -- **Required**: `workItemId`, `project` -- **Optional**: `artifactUri`, `branchName`, `buildId`, `comment`, `commitId`, `linkType`, `projectId`, `pullRequestId`, `repositoryId` - -#### mcp_ado_wit_link_work_item_to_pull_request - -Link a single work item to an existing pull request. - -- **Required**: `projectId`, `repositoryId`, `pullRequestId`, `workItemId` -- **Optional**: `pullRequestProjectId` - -#### mcp_ado_wit_list_work_item_comments - -Retrieve list of comments for a work item by ID. - -- **Required**: `project`, `workItemId` -- **Optional**: `top` - -#### mcp_ado_wit_add_work_item_comment - -Add comment to a work item by ID. - -- **Required**: `project`, `workItemId`, `comment` -- **Optional**: `format` - -#### mcp_ado_wit_update_work_item_comment - -Update an existing comment on a work item by ID. - -- **Required**: `project`, `workItemId`, `commentId`, `text` -- **Optional**: `format` - -#### mcp_ado_wit_list_work_item_revisions - -Retrieve list of revisions for a work item by ID. - -- **Required**: `project`, `workItemId` -- **Optional**: `expand`, `skip`, `top` - -#### mcp_ado_wit_get_work_item_type - -Get a specific work item type. - -- **Required**: `project`, `workItemType` -- **Optional**: None - -#### mcp_ado_wit_my_work_items - -Retrieve a list of work items relevant to the authenticated user. - -- **Required**: `project` -- **Optional**: `includeCompleted`, `top`, `type` - -#### mcp_ado_wit_get_work_items_for_iteration - -Retrieve a list of work items for a specified iteration. - -- **Required**: `project`, `iterationId` -- **Optional**: `team` - -#### mcp_ado_wit_list_backlogs - -Receive a list of backlogs for a given project and team. - -- **Required**: `project`, `team` -- **Optional**: None - -#### mcp_ado_wit_list_backlog_work_items - -Retrieve a list of backlogs for a given project, team, and backlog category. - -- **Required**: `project`, `team`, `backlogId` -- **Optional**: None - -#### mcp_ado_wit_get_query - -Get a query by its ID or path. - -- **Required**: `project`, `query` -- **Optional**: `depth`, `expand`, `includeDeleted`, `useIsoDateFormat` - -#### mcp_ado_wit_get_query_results_by_id - -Retrieve the results of a work item query given the query ID. - -- **Required**: `id` -- **Optional**: `project`, `responseType`, `team`, `timePrecision`, `top` - -#### mcp_ado_wit_query_by_wiql - -Execute a WIQL (Work Item Query Language) query and return the matching work items. If a project is not specified, you will be prompted to select one. - -- **Required**: `wiql` -- **Optional**: `project`, `team`, `timePrecision`, `top` - -#### mcp_ado_wit_get_work_item_attachment - -Download a work item attachment by its ID. If `savePath` is provided, saves the file to that local directory and returns the file path. Otherwise returns the content as a base64-encoded resource. Useful for viewing images (e.g. screenshots) or other files attached to work items such as bugs. - -- **Required**: `attachmentId` -- **Optional**: `project`, `fileName`, `savePath` - -### Work - -> **Note:** The work tools are being aligned with the [Azure DevOps remote MCP server](https://learn.microsoft.com/en-us/azure/devops/mcp-server/remote-mcp-server?view=azure-devops#work) tool structure. +| Tool | Description | +| --------------------------------------------------- | ------------------------------------- | +| [mcp_ado_search_code](#mcp_ado_search_code) | Search for code across repositories | +| [mcp_ado_search_wiki](#mcp_ado_search_wiki) | Search wiki pages by keywords | +| [mcp_ado_search_workitem](#mcp_ado_search_workitem) | Search work items by text and filters | -The work tools are consolidated into grouped dispatchers using an `action` parameter. +### Advanced Security -| Tool | Action | Description | Read-only | -| ---------------------- | -------------------------- | --------------------------------------------------------------------------------------- | :-------: | -| `work` | `list_iterations` | List all iterations in a project | ✅ | -| `work` | `list_team_iterations` | List iterations assigned to a team | ✅ | -| `work` | `get_team_settings` | Get team settings including default iteration, backlog iteration, and default area path | ✅ | -| `work` | `get_team_capacity` | Get team capacity for an iteration | ✅ | -| `work` | `get_iteration_capacities` | Get an iteration's capacity for all teams in the iteration and project | ✅ | -| `work_iteration_write` | `create` | Create iterations | ❌ | -| `work_iteration_write` | `assign` | Assign iterations to a team | ❌ | -| `work_capacity_write` | `update` | Update the team capacity of a team member for a specific iteration | ❌ | +| Tool | Description | +| --------------------------------------------------------------------- | -------------------------------------------------------- | +| [mcp_ado_advsec_get_alerts](#mcp_ado_advsec_get_alerts) | Retrieve Advanced Security alerts for a repository | +| [mcp_ado_advsec_get_alert_details](#mcp_ado_advsec_get_alert_details) | Get detailed information about a specific security alert | diff --git a/jest.config.cjs b/jest.config.cjs index 54d4e171..6fd59f47 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -53,6 +53,8 @@ module.exports = { "^(.+)/logger\\.js$": "$1/logger.ts", "^(.+)/elicitations\\.js$": "$1/elicitations.ts", "^(.+)/content-safety\\.js$": "$1/content-safety.ts", + "^(.+)/command\\.js$": "$1/command.ts", + "^(.+)/pipelines\\.dto\\.js$": "$1/pipelines.dto.ts", "^(.+)/index\\.js$": "$1/index.ts", }, }; diff --git a/package-lock.json b/package-lock.json index 04a17bc5..1ef9f7de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure-devops/mcp", - "version": "2.8.1", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-devops/mcp", - "version": "2.8.1", + "version": "2.9.0", "license": "MIT", "dependencies": { "@azure/identity": "^4.10.0", @@ -2719,9 +2719,9 @@ "license": "MIT" }, "node_modules/azure-devops-extension-api": { - "version": "5.274.0", - "resolved": "https://registry.npmjs.org/azure-devops-extension-api/-/azure-devops-extension-api-5.274.0.tgz", - "integrity": "sha512-upVglA4T3L+aVTNJ9rdMDeb/t0aonKg6cf74GpcP2ayFQ7XTyG9MYba5Wc5qGdcnrwYDMO40P6rU/DwT+mqAQA==", + "version": "5.275.0", + "resolved": "https://registry.npmjs.org/azure-devops-extension-api/-/azure-devops-extension-api-5.275.0.tgz", + "integrity": "sha512-uSWDVmOFjeX7ALZqFN2qvfE4x7f4+hxkrEK3wCiKFbaQuTgOObIXVMXYVtutqhe7KXzQxhCuWYlYhi890s7WXA==", "license": "MIT", "dependencies": { "whatwg-fetch": "~3.0.0" @@ -2869,20 +2869,20 @@ } }, "node_modules/body-parser": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", - "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.0", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2892,6 +2892,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -3143,56 +3156,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -3640,19 +3603,6 @@ "once": "^1.4.0" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3933,13 +3883,6 @@ "node": ">= 0.6" } }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -4133,9 +4076,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -4583,9 +4526,9 @@ } }, "node_modules/hono": { - "version": "4.12.25", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", - "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4671,9 +4614,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4774,9 +4717,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", "license": "MIT", "engines": { "node": ">= 12" @@ -4839,22 +4782,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", @@ -6154,14 +6081,13 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.1.0.tgz", + "integrity": "sha512-d7UQRu/9ZPgfu4+hu/k0wny5GEaIxo+2jb2LJqQDkE7cHRTm1HGqNUDq5UOwsGPpjpaNAFmgAsYo3TR+i9cSJw==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "picomatch": "^4.0.5", "string-argv": "^0.3.2", "tinyexec": "^1.2.4" }, @@ -6179,9 +6105,9 @@ } }, "node_modules/lint-staged/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -6191,71 +6117,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/listr2/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/listr2/node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6330,115 +6191,6 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/logform": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", @@ -6608,19 +6360,6 @@ "node": ">=6" } }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -7411,39 +7150,6 @@ "node": ">=4" } }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/restore-cursor/node_modules/onetime": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7455,13 +7161,6 @@ "node": ">=0.10.0" } }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -7889,36 +7588,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -8398,9 +8067,9 @@ } }, "node_modules/ts-jest": { - "version": "29.4.11", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.11.tgz", - "integrity": "sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==", + "version": "29.4.12", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", + "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", "dev": true, "license": "MIT", "dependencies": { @@ -8410,7 +8079,7 @@ "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.8.0", + "semver": "^7.8.5", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -8451,9 +8120,9 @@ } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", - "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -8554,17 +8223,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-rest-client": { diff --git a/package.json b/package.json index 61f09a73..2e086d19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure-devops/mcp", - "version": "2.8.1", + "version": "2.9.0", "mcpName": "microsoft.com/azure-devops", "description": "MCP server for interacting with Azure DevOps", "license": "MIT", diff --git a/server.json b/server.json index 5bd57ebf..7b54348a 100644 --- a/server.json +++ b/server.json @@ -4,6 +4,18 @@ "description": "The MCP server for Azure DevOps, bringing the power of Azure DevOps directly to your agents.", "version": "2.4.0", "title": "Azure DevOps", + "remotes": [ + { + "type": "streamable-http", + "url": "https://mcp.dev.azure.com/{organization}", + "variables": { + "organization": { + "description": "Your Azure DevOps organization identifier (e.g., 'contoso', 'fabrikam')", + "isRequired": false + } + } + } + ], "packages": [ { "registryType": "npm", diff --git a/src/shared/command.ts b/src/shared/command.ts new file mode 100644 index 00000000..e0e6f6b8 --- /dev/null +++ b/src/shared/command.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { WebApi } from "azure-devops-node-api"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z, ZodRawShape } from "zod"; + +/** + * Shared infrastructure available to every command. + * + * Command-specific data is passed separately via the typed args object, so the + * `Command` interface stays stable even as individual commands evolve. This is + * the same "context + typed args" split used by frameworks like Express and + * NestJS: shared infrastructure goes into the context, request-specific data + * goes into the arguments. + */ +export interface CommandContext { + /** Returns an authenticated Azure DevOps Web API connection. */ + connectionProvider: () => Promise; + /** Returns a bearer token for direct REST calls. */ + tokenProvider: () => Promise; + /** Returns the User-Agent string to attach to outbound requests. */ + userAgentProvider: () => string; +} + +/** + * Infers the typed args object from the Zod raw shape that is passed to + * `server.tool(...)`. Lets a command declare a single strongly-typed argument + * object instead of a long positional parameter list. + */ +export type CommandArgs = z.infer>; + +/** + * A command encapsulates the behavior of a single MCP tool action. + * + * Shared infrastructure is received via `context`; command-specific input via + * the typed `args` object. + * + * @example + * const searchCommand: Command = { + * async execute(context, args) { + * const connection = await context.connectionProvider(); + * // ...use args.query, args.maxResults, etc. + * }, + * }; + */ +export interface Command { + execute(context: CommandContext, args: TArgs): Promise; +} + +/** A registry that maps each action name to the command that handles it. */ +export type CommandRegistry = Partial>>; + +/** Builds an error `CallToolResult`. */ +export const errorResult = (text: string): CallToolResult => ({ content: [{ type: "text", text }], isError: true }); + +/** + * Routes a validated, action-carrying args object to the matching command. + * + * This is what removes long positional parameter lists from grouped ("action") + * tools: instead of destructuring every possible field, the whole typed args + * object is forwarded to the single command keyed by `args.action`, coupling + * each action to exactly one command. + * + * - Unknown actions short-circuit with an "Unknown action" error and never + * touch the context (so no connection is opened). + * - Errors thrown by a command are caught and formatted using the optional + * per-action `errorPrefixes` map (falling back to a generic message). + * - Errors returned by a command (e.g. validation `errorResult`s) pass through + * unchanged. + */ +export async function dispatchAction( + commands: CommandRegistry, + context: CommandContext, + args: TArgs, + errorPrefixes?: Partial> +): Promise { + const command = commands[args.action as TArgs["action"]]; + if (!command) { + const supportedActions = Object.keys(commands).sort().join(", "); + return errorResult(`Unknown action: ${args.action}. Supported actions: ${supportedActions}`); + } + + try { + return await command.execute(context, args); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error occurred"; + const prefix = errorPrefixes?.[args.action as TArgs["action"]]; + return errorResult(prefix ? `${prefix}${message}` : `Error: ${message}`); + } +} diff --git a/src/tools/auth.ts b/src/tools/auth.ts index 3920a3e8..5da1b85e 100644 --- a/src/tools/auth.ts +++ b/src/tools/auth.ts @@ -59,10 +59,12 @@ async function searchIdentities(identity: string, tokenProvider: () => Promise Promise, connectionProvider: () => Promise, userAgentProvider: () => string): Promise { +async function getUserIdentityFromEmail( + userEmail: string, + tokenProvider: () => Promise, + connectionProvider: () => Promise, + userAgentProvider: () => string +): Promise<{ id: string; displayName: string }> { const identities = await searchIdentities(userEmail, tokenProvider, connectionProvider, userAgentProvider); if (!identities || identities.value?.length === 0) { @@ -74,7 +76,15 @@ async function getUserIdFromEmail(userEmail: string, tokenProvider: () => Promis throw new Error(`No ID found for user with email/unique name: ${userEmail}`); } - return firstIdentity.id; + return { id: firstIdentity.id, displayName: firstIdentity.providerDisplayName ?? userEmail }; +} + +/** + * Gets the user ID from email or unique name using Azure DevOps Identity API + */ +async function getUserIdFromEmail(userEmail: string, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string): Promise { + const identity = await getUserIdentityFromEmail(userEmail, tokenProvider, connectionProvider, userAgentProvider); + return identity.id; } -export { getCurrentUserDetails, getUserIdFromEmail, searchIdentities }; +export { getCurrentUserDetails, getUserIdFromEmail, getUserIdentityFromEmail, searchIdentities }; diff --git a/src/tools/pipelines.dto.ts b/src/tools/pipelines.dto.ts new file mode 100644 index 00000000..094d0598 --- /dev/null +++ b/src/tools/pipelines.dto.ts @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { getEnumKeys } from "../utils.js"; +import { RepositoryType } from "azure-devops-node-api/interfaces/PipelinesInterfaces.js"; +import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; + +// ───────────────────────────────────────────────────────────────────────────── +// DTOs for the pipelines_write tool. +// +// Each action's inputs are declared once as a Zod "raw shape". The shapes are +// the single source of truth: the tool's input schema is composed from them, +// and the TypeScript argument types are derived via `z.infer` (no hand-written, +// drift-prone duplicate types). These types are safe to export — they are +// compile-time only and erased at runtime, so they have no effect on the MCP +// protocol or a local server. +// ───────────────────────────────────────────────────────────────────────────── + +export const variableSchema = z.object({ + value: z.string().optional(), + isSecret: z.boolean().optional(), +}); + +export const resourcesSchema = z.object({ + builds: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), + containers: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), + packages: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), + pipelines: z + .record( + z.string(), + z.object({ + runId: z.coerce.number().min(1).optional().describe("Id of the source pipeline run."), + version: z.string().optional(), + }) + ) + .optional(), + repositories: z + .record( + z.string(), + z.object({ + refName: z.string().describe("Reference name, e.g., refs/heads/main."), + token: z.string().optional(), + tokenType: z.string().optional(), + version: z.string().optional(), + }) + ) + .optional(), +}); + +/** Fields shared by every write action. */ +const projectShape = { + project: z.string().describe("Project ID or name."), +}; + +/** run_pipeline inputs. */ +export const runPipelineShape = { + ...projectShape, + pipelineId: z.coerce.number().min(1).optional().describe("ID of the pipeline to run. Required for: run_pipeline."), + pipelineVersion: z.coerce.number().min(1).optional().describe("Version of the pipeline to run. Used for: run_pipeline."), + previewRun: z.boolean().optional().describe("If true, returns the final YAML without creating a run. Used for: run_pipeline."), + resources: resourcesSchema.optional().describe("Resources to pass to the pipeline. Used for: run_pipeline."), + stagesToSkip: z.array(z.string()).optional().describe("Stages to skip. Used for: run_pipeline."), + templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs. Used for: run_pipeline."), + variables: z.record(z.string(), variableSchema).optional().describe("Variables to pass to the pipeline. Used for: run_pipeline."), + yamlOverride: z.string().optional().describe("YAML override (only valid with previewRun). Used for: run_pipeline."), +}; + +/** create_pipeline inputs. */ +export const createPipelineShape = { + ...projectShape, + name: z.string().optional().describe("Name of the new pipeline. Required for: create_pipeline."), + folder: z.string().optional().describe("Folder path for the new pipeline. Used for: create_pipeline."), + yamlPath: z.string().optional().describe("Path to the YAML file in the repository. Required for: create_pipeline."), + repositoryType: z + .enum(getEnumKeys(RepositoryType) as [string, ...string[]]) + .optional() + .describe("Type of the repository. Required for: create_pipeline."), + repositoryName: z.string().optional().describe("Name of the repository (for GitHub: owner/repo). Required for: create_pipeline."), + repositoryId: z.string().optional().describe("ID of the repository. Used for: create_pipeline."), + repositoryConnectionId: z.string().optional().describe("Service connection ID for GitHub repositories. Used for: create_pipeline."), +}; + +/** update_build_stage inputs. */ +export const updateBuildStageShape = { + ...projectShape, + buildId: z.coerce.number().min(1).optional().describe("ID of the build to update. Required for: update_build_stage."), + stageName: z.string().optional().describe("Name of the stage to update. Required for: update_build_stage."), + status: z + .enum(getEnumKeys(StageUpdateType) as [string, ...string[]]) + .optional() + .describe("New status for the stage. Required for: update_build_stage."), + forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage. Used for: update_build_stage."), +}; + +/** The composed input shape for the grouped `pipelines_write` tool. */ +export const pipelinesWriteShape = { + action: z + .enum(["run_pipeline", "create_pipeline", "update_build_stage"]) + .describe( + "The action to perform. Options: run_pipeline (queue a new pipeline run), create_pipeline (create a new YAML pipeline definition), update_build_stage (cancel, retry, or run a stage on an in-flight build)." + ), + ...runPipelineShape, + ...createPipelineShape, + ...updateBuildStageShape, +}; + +// Per-action schemas + inferred argument DTOs. `z.infer` keeps these types in +// lockstep with the schemas above. +export const runPipelineSchema = z.object(runPipelineShape); +export const createPipelineSchema = z.object(createPipelineShape); +export const updateBuildStageSchema = z.object(updateBuildStageShape); +export const pipelinesWriteSchema = z.object(pipelinesWriteShape); + +export type RunPipelineArgs = z.infer; +export type CreatePipelineArgs = z.infer; +export type UpdateBuildStageArgs = z.infer; +export type PipelinesWriteArgs = z.infer; diff --git a/src/tools/pipelines.ts b/src/tools/pipelines.ts index cb63a66a..bda29bf6 100644 --- a/src/tools/pipelines.ts +++ b/src/tools/pipelines.ts @@ -11,6 +11,118 @@ import { ConfigurationType, RepositoryType } from "azure-devops-node-api/interfa import { mkdirSync, createWriteStream } from "fs"; import { createExternalContentResponse } from "../shared/content-safety.js"; import { join, posix, resolve, win32 } from "path"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { Command, CommandContext, CommandRegistry, dispatchAction, errorResult } from "../shared/command.js"; +import { pipelinesWriteShape, RunPipelineArgs, CreatePipelineArgs, UpdateBuildStageArgs, PipelinesWriteArgs } from "./pipelines.dto.js"; + +// ─── pipelines_write commands ──────────────────────────────────────────────── +// Each write action is a self-contained command. Shared infrastructure arrives +// via `CommandContext`; the action-specific input arrives via a single typed +// args object (see pipelines.dto.ts). This keeps the dispatcher agnostic of +// individual argument lists. + +const runPipelineCommand: Command = { + async execute(context: CommandContext, args: RunPipelineArgs): Promise { + if (!args.pipelineId) return errorResult("pipelineId is required for run_pipeline"); + if (!args.previewRun && args.yamlOverride) throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); + + const connection = await context.connectionProvider(); + const pipelinesApi = await connection.getPipelinesApi(); + const runRequest = { + previewRun: args.previewRun, + resources: { ...args.resources }, + stagesToSkip: args.stagesToSkip, + templateParameters: args.templateParameters, + variables: args.variables, + yamlOverride: args.yamlOverride, + }; + const pipelineRun = await pipelinesApi.runPipeline(runRequest, args.project, args.pipelineId, args.pipelineVersion); + + if (pipelineRun.id === undefined) throw new Error("Failed to get build ID from pipeline run"); + + return { content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }] }; + }, +}; + +const createPipelineCommand: Command = { + async execute(context: CommandContext, args: CreatePipelineArgs): Promise { + if (!args.name) return errorResult("name is required for create_pipeline"); + if (!args.yamlPath) return errorResult("yamlPath is required for create_pipeline"); + if (!args.repositoryType) return errorResult("repositoryType is required for create_pipeline"); + if (!args.repositoryName) return errorResult("repositoryName is required for create_pipeline"); + + const connection = await context.connectionProvider(); + const pipelinesApi = await connection.getPipelinesApi(); + const repositoryTypeEnumValue = safeEnumConvert(RepositoryType, args.repositoryType); + const repositoryPayload: Record = { type: args.repositoryType }; + + if (repositoryTypeEnumValue === RepositoryType.AzureReposGit) { + repositoryPayload.id = args.repositoryId; + repositoryPayload.name = args.repositoryName; + } else if (repositoryTypeEnumValue === RepositoryType.GitHub) { + if (!args.repositoryConnectionId) throw new Error("Parameter 'repositoryConnectionId' is required for GitHub repositories."); + repositoryPayload.connection = { id: args.repositoryConnectionId }; + repositoryPayload.fullname = args.repositoryName; + } else { + throw new Error("Unsupported repository type"); + } + + const yamlConfigurationType = getEnumKeys(ConfigurationType).find((k) => ConfigurationType[k as keyof typeof ConfigurationType] === ConfigurationType.Yaml); + const createParams: Record = { + name: args.name, + folder: args.folder || "\\", + configuration: { type: yamlConfigurationType, path: args.yamlPath, repository: repositoryPayload, variables: undefined }, + }; + const newPipeline = await pipelinesApi.createPipeline(createParams, args.project); + + return { content: [{ type: "text", text: JSON.stringify(newPipeline, null, 2) }] }; + }, +}; + +const updateBuildStageCommand: Command = { + async execute(context: CommandContext, args: UpdateBuildStageArgs): Promise { + if (!args.buildId) return errorResult("buildId is required for update_build_stage"); + if (!args.stageName) return errorResult("stageName is required for update_build_stage"); + if (!args.status) return errorResult("status is required for update_build_stage"); + + const connection = await context.connectionProvider(); + const orgUrl = connection.serverUrl; + const endpoint = `${orgUrl}/${encodeURIComponent(args.project)}/_apis/build/builds/${args.buildId}/stages/${encodeURIComponent(args.stageName)}?api-version=${apiVersion}`; + const token = await context.tokenProvider(); + const body = { forceRetryAllJobs: args.forceRetryAllJobs, state: safeEnumConvert(StageUpdateType, args.status) }; + const response = await fetch(endpoint, { + method: "PATCH", + headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": context.userAgentProvider() }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to update build stage: ${response.status} ${errorText}`); + } + + const updatedBuild = await response.text(); + + return { content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }] }; + }, +}; + +/** + * The registry is the lookup table that couples each action to its command. + * Adding a new write action means registering one entry here — the dispatcher + * (`dispatchAction`) never changes. + */ +const pipelinesWriteCommands: CommandRegistry = { + run_pipeline: runPipelineCommand, + create_pipeline: createPipelineCommand, + update_build_stage: updateBuildStageCommand, +}; + +const pipelinesWriteErrorPrefixes: Record = { + run_pipeline: "Error running pipeline: ", + create_pipeline: "Error creating pipeline: ", + update_build_stage: "Error updating build stage: ", +}; const PIPELINE_TOOLS = { pipelines_build: "pipelines_build", @@ -329,7 +441,6 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< } ); - // ─── pipelines_artifact ───────────────────────────────────────────────────── server.tool( PIPELINE_TOOLS.pipelines_artifact, "Retrieve and download build artifacts. Use the action parameter to specify the operation.", @@ -419,189 +530,12 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< } ); - const variableSchema = z.object({ - value: z.string().optional(), - isSecret: z.boolean().optional(), - }); - - const resourcesSchema = z.object({ - builds: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), - containers: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), - packages: z.record(z.string(), z.object({ version: z.string().optional() })).optional(), - pipelines: z.record( - z.string(), - z.object({ - runId: z.coerce.number().min(1).optional().describe("Id of the source pipeline run."), - version: z.string().optional(), - }) - ), - repositories: z - .record( - z.string(), - z.object({ - refName: z.string().describe("Reference name, e.g., refs/heads/main."), - token: z.string().optional(), - tokenType: z.string().optional(), - version: z.string().optional(), - }) - ) - .optional(), - }); - // ─── pipelines_write ──────────────────────────────────────────────────────── - server.tool( - PIPELINE_TOOLS.pipelines_write, - "Write operations for pipelines and builds. Use the action parameter to specify the operation.", - { - action: z - .enum(["run_pipeline", "create_pipeline", "update_build_stage"]) - .describe( - "The action to perform. Options: run_pipeline (queue a new pipeline run), create_pipeline (create a new YAML pipeline definition), update_build_stage (cancel, retry, or run a stage on an in-flight build)." - ), - project: z.string().describe("Project ID or name."), - // run_pipeline params - pipelineId: z.coerce.number().min(1).optional().describe("ID of the pipeline to run. Required for: run_pipeline."), - pipelineVersion: z.coerce.number().min(1).optional().describe("Version of the pipeline to run. Used for: run_pipeline."), - previewRun: z.boolean().optional().describe("If true, returns the final YAML without creating a run. Used for: run_pipeline."), - resources: resourcesSchema.optional().describe("Resources to pass to the pipeline. Used for: run_pipeline."), - stagesToSkip: z.array(z.string()).optional().describe("Stages to skip. Used for: run_pipeline."), - templateParameters: z.record(z.string(), z.string()).optional().describe("Custom build parameters as key-value pairs. Used for: run_pipeline."), - variables: z.record(z.string(), variableSchema).optional().describe("Variables to pass to the pipeline. Used for: run_pipeline."), - yamlOverride: z.string().optional().describe("YAML override (only valid with previewRun). Used for: run_pipeline."), - // create_pipeline params - name: z.string().optional().describe("Name of the new pipeline. Required for: create_pipeline."), - folder: z.string().optional().describe("Folder path for the new pipeline. Used for: create_pipeline."), - yamlPath: z.string().optional().describe("Path to the YAML file in the repository. Required for: create_pipeline."), - repositoryType: z - .enum(getEnumKeys(RepositoryType) as [string, ...string[]]) - .optional() - .describe("Type of the repository. Required for: create_pipeline."), - repositoryName: z.string().optional().describe("Name of the repository (for GitHub: owner/repo). Required for: create_pipeline."), - repositoryId: z.string().optional().describe("ID of the repository. Used for: create_pipeline."), - repositoryConnectionId: z.string().optional().describe("Service connection ID for GitHub repositories. Used for: create_pipeline."), - // update_build_stage params - buildId: z.coerce.number().min(1).optional().describe("ID of the build to update. Required for: update_build_stage."), - stageName: z.string().optional().describe("Name of the stage to update. Required for: update_build_stage."), - status: z - .enum(getEnumKeys(StageUpdateType) as [string, ...string[]]) - .optional() - .describe("New status for the stage. Required for: update_build_stage."), - forceRetryAllJobs: z.boolean().default(false).describe("Whether to force retry all jobs in the stage. Used for: update_build_stage."), - }, - async ({ - action, - project, - pipelineId, - pipelineVersion, - previewRun, - resources, - stagesToSkip, - templateParameters, - variables, - yamlOverride, - name, - folder, - yamlPath, - repositoryType, - repositoryName, - repositoryId, - repositoryConnectionId, - buildId, - stageName, - status, - forceRetryAllJobs, - }) => { - try { - if (action === "run_pipeline") { - if (!pipelineId) return { content: [{ type: "text", text: "pipelineId is required for run_pipeline" }], isError: true }; - if (!previewRun && yamlOverride) throw new Error("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); - } - - if (action === "create_pipeline") { - if (!name) return { content: [{ type: "text", text: "name is required for create_pipeline" }], isError: true }; - if (!yamlPath) return { content: [{ type: "text", text: "yamlPath is required for create_pipeline" }], isError: true }; - if (!repositoryType) return { content: [{ type: "text", text: "repositoryType is required for create_pipeline" }], isError: true }; - if (!repositoryName) return { content: [{ type: "text", text: "repositoryName is required for create_pipeline" }], isError: true }; - } - - if (action === "update_build_stage") { - if (!buildId) return { content: [{ type: "text", text: "buildId is required for update_build_stage" }], isError: true }; - if (!stageName) return { content: [{ type: "text", text: "stageName is required for update_build_stage" }], isError: true }; - if (!status) return { content: [{ type: "text", text: "status is required for update_build_stage" }], isError: true }; - } - - const connection = await connectionProvider(); - - if (action === "run_pipeline") { - const pipelinesApi = await connection.getPipelinesApi(); - const runRequest = { previewRun, resources: { ...resources }, stagesToSkip, templateParameters, variables, yamlOverride }; - const pipelineRun = await pipelinesApi.runPipeline(runRequest, project, pipelineId as number, pipelineVersion); - - if (pipelineRun.id === undefined) throw new Error("Failed to get build ID from pipeline run"); - - return { content: [{ type: "text", text: JSON.stringify(pipelineRun, null, 2) }] }; - } - - if (action === "create_pipeline") { - const pipelinesApi = await connection.getPipelinesApi(); - const repositoryTypeEnumValue = safeEnumConvert(RepositoryType, repositoryType); - const repositoryPayload: Record = { type: repositoryType }; - - if (repositoryTypeEnumValue === RepositoryType.AzureReposGit) { - repositoryPayload.id = repositoryId; - repositoryPayload.name = repositoryName; - } else if (repositoryTypeEnumValue === RepositoryType.GitHub) { - if (!repositoryConnectionId) throw new Error("Parameter 'repositoryConnectionId' is required for GitHub repositories."); - repositoryPayload.connection = { id: repositoryConnectionId }; - repositoryPayload.fullname = repositoryName; - } else { - throw new Error("Unsupported repository type"); - } - - const yamlConfigurationType = getEnumKeys(ConfigurationType).find((k) => ConfigurationType[k as keyof typeof ConfigurationType] === ConfigurationType.Yaml); - const createParams: Record = { - name, - folder: folder || "\\", - configuration: { type: yamlConfigurationType, path: yamlPath, repository: repositoryPayload, variables: undefined }, - }; - const newPipeline = await pipelinesApi.createPipeline(createParams, project); - - return { content: [{ type: "text", text: JSON.stringify(newPipeline, null, 2) }] }; - } - - if (action === "update_build_stage") { - const orgUrl = connection.serverUrl; - const endpoint = `${orgUrl}/${encodeURIComponent(project)}/_apis/build/builds/${buildId as number}/stages/${encodeURIComponent(stageName as string)}?api-version=${apiVersion}`; - const token = await tokenProvider(); - const body = { forceRetryAllJobs, state: safeEnumConvert(StageUpdateType, status as string) }; - const response = await fetch(endpoint, { - method: "PATCH", - headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, "User-Agent": userAgentProvider() }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update build stage: ${response.status} ${errorText}`); - } - - const updatedBuild = await response.text(); - - return { content: [{ type: "text", text: JSON.stringify(updatedBuild, null, 2) }] }; - } - - return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - const msgs: Record = { - run_pipeline: `Error running pipeline: ${errorMessage}`, - create_pipeline: `Error creating pipeline: ${errorMessage}`, - update_build_stage: `Error updating build stage: ${errorMessage}`, - }; - return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; - } - } - ); + server.tool(PIPELINE_TOOLS.pipelines_write, "Write operations for pipelines and builds. Use the action parameter to specify the operation.", pipelinesWriteShape, async (args) => { + const context: CommandContext = { connectionProvider, tokenProvider, userAgentProvider }; + return dispatchAction(pipelinesWriteCommands, context, args as unknown as PipelinesWriteArgs, pipelinesWriteErrorPrefixes); + }); } -export { PIPELINE_TOOLS, configurePipelineTools }; +export { PIPELINE_TOOLS, configurePipelineTools, runPipelineCommand, createPipelineCommand, updateBuildStageCommand }; +export type { RunPipelineArgs, CreatePipelineArgs, UpdateBuildStageArgs }; diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index aa4facad..94048f1c 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -19,7 +19,6 @@ import { GitPullRequest, GitPullRequestCommentThread, Comment, - VersionControlChangeType, VersionControlRecursionType, } from "azure-devops-node-api/interfaces/GitInterfaces.js"; import { z } from "zod"; @@ -30,28 +29,15 @@ import { extractAdoStreamError, getEnumKeys, streamToString, apiVersion } from " import { orgName } from "../index.js"; const REPO_TOOLS = { - list_repos_by_project: "repo_list_repos_by_project", - list_pull_requests_by_repo_or_project: "repo_list_pull_requests_by_repo_or_project", - list_branches_by_repo: "repo_list_branches_by_repo", - list_my_branches_by_repo: "repo_list_my_branches_by_repo", - list_pull_request_threads: "repo_list_pull_request_threads", - list_pull_request_thread_comments: "repo_list_pull_request_thread_comments", - get_repo_by_name_or_id: "repo_get_repo_by_name_or_id", - get_branch_by_name: "repo_get_branch_by_name", - get_pull_request_by_id: "repo_get_pull_request_by_id", - get_pull_request_changes: "repo_get_pull_request_changes", - create_pull_request: "repo_create_pull_request", - create_branch: "repo_create_branch", - update_pull_request: "repo_update_pull_request", - update_pull_request_reviewers: "repo_update_pull_request_reviewers", - reply_to_comment: "repo_reply_to_comment", - create_pull_request_thread: "repo_create_pull_request_thread", - update_pull_request_thread: "repo_update_pull_request_thread", - search_commits: "repo_search_commits", - list_pull_requests_by_commits: "repo_list_pull_requests_by_commits", - vote_pull_request: "repo_vote_pull_request", - list_directory: "repo_list_directory", - get_file_content: "repo_get_file_content", + repo_repository: "repo_repository", + repo_pull_request: "repo_pull_request", + repo_pull_request_thread: "repo_pull_request_thread", + repo_branch: "repo_branch", + repo_file: "repo_file", + repo_search_commits: "repo_search_commits", + repo_pull_request_write: "repo_pull_request_write", + repo_pull_request_thread_write: "repo_pull_request_thread_write", + repo_create_branch: "repo_create_branch", }; function branchesFilterOutIrrelevantProperties(branches: GitRef[], top: number) { @@ -74,14 +60,9 @@ function trimPullRequestThread(thread: GitPullRequestCommentThread) { }; } -/** - * Trims comment data to essential properties, filtering out deleted comments - * @param comments Array of comments to trim (can be undefined/null) - * @returns Array of trimmed comment objects with essential properties only - */ function trimComments(comments: Comment[] | undefined | null) { return comments - ?.filter((comment) => !comment.isDeleted) // Exclude deleted comments + ?.filter((comment) => !comment.isDeleted) ?.map((comment) => ({ id: comment.id, author: { @@ -114,18 +95,14 @@ function pullRequestStatusStringToInt(status: string): number { function filterReposByName(repositories: GitRepository[], repoNameFilter: string): GitRepository[] { const lowerCaseFilter = repoNameFilter.toLowerCase(); - const filteredByName = repositories?.filter((repo) => repo.name?.toLowerCase().includes(lowerCaseFilter)); - - return filteredByName; + return repositories?.filter((repo) => repo.name?.toLowerCase().includes(lowerCaseFilter)); } function trimPullRequest(pr: GitPullRequest | null | undefined, includeDescription = false) { if (!pr) { return null; } - const statusName = typeof pr.status === "number" ? (PullRequestStatus[pr.status] ?? "Unknown") : "Unknown"; - return { pullRequestId: pr.pullRequestId, codeReviewId: pr.codeReviewId, @@ -147,1917 +124,974 @@ function trimPullRequest(pr: GitPullRequest | null | undefined, includeDescripti }; } -// Helper function to build a version descriptor from branch or commit function buildVersionDescriptor(version?: string, versionType?: string): GitVersionDescriptor | undefined { - if (!version) { - return undefined; - } - + if (!version) return undefined; const versionTypeMap: Record = { Branch: GitVersionType.Branch, Commit: GitVersionType.Commit, Tag: GitVersionType.Tag, }; - return { - version: version, + version, versionType: versionTypeMap[versionType || "Branch"] ?? GitVersionType.Branch, }; } function configureRepoTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { + // --- repo_repository ------------------------------------------------------- server.tool( - REPO_TOOLS.create_pull_request, - "Create a new pull request.", + REPO_TOOLS.repo_repository, + "Retrieve repository data for an organization or project. Use the action parameter to specify the operation.", { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request will be created. When using a repository name instead of a GUID, the project parameter must also be provided."), - sourceRefName: z.string().describe("The source branch name for the pull request, e.g., 'refs/heads/feature-branch'."), - targetRefName: z.string().describe("The target branch name for the pull request, e.g., 'refs/heads/main'."), - title: z.string().describe("The title of the pull request."), - description: z.string().max(4000).optional().describe("The description of the pull request. Must not be longer than 4000 characters. Optional."), - isDraft: z.boolean().optional().default(false).describe("Indicates whether the pull request is a draft. Defaults to false."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - workItems: z.string().optional().describe("Work item IDs to associate with the pull request, space-separated."), - forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository that the pull request originates from. Optional, used when creating a pull request from a fork."), - labels: z.array(z.string()).optional().describe("Array of label names to add to the pull request after creation."), + action: z.enum(["get", "list"]).describe("The action to perform. Options: get (get a repository by name or ID), list (list repositories in a project)."), + project: z.string().optional().describe("The name or ID of the Azure DevOps project. Required for get and list."), + repositoryNameOrId: z.string().optional().describe("Repository name or ID. Required for get."), + top: z.coerce.number().default(100).describe("The maximum number of repositories to return. Used for list. Defaults to 100."), + skip: z.coerce.number().default(0).describe("The number of repositories to skip. Used for list. Defaults to 0."), + repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. Used for list."), }, - async ({ repositoryId, sourceRefName, targetRefName, title, description, isDraft, project, workItems, forkSourceRepositoryId, labels }) => { + async ({ action, project, repositoryNameOrId, top, skip, repoNameFilter }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : []; - const noDataErrorMessage = - `Pull request creation returned no data and no matching PR was found. This often means repositoryId=\"${repositoryId}\" was not resolvable. ` + - "Try the repository GUID from repo_list_repos_by_project instead of the Project/RepoName slash format."; - - const forkSource: GitForkRef | undefined = forkSourceRepositoryId - ? { - repository: { - id: forkSourceRepositoryId, - }, - } - : undefined; - - const labelDefinitions: WebApiTagDefinition[] | undefined = labels ? labels.map((label) => ({ name: label })) : undefined; - - let pullRequest = await gitApi.createPullRequest( - { - sourceRefName, - targetRefName, - title, - description, - isDraft, - workItemRefs: workItemRefs, - forkSource, - labels: labelDefinitions, - supportsIterations: true, - }, - repositoryId, - project - ); - - if (!pullRequest) { - const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1); - if (prs && prs.length > 0) { - pullRequest = prs[0]; - } else { - return { - content: [{ type: "text", text: noDataErrorMessage }], - isError: true, - }; - } - } - - const trimmedPullRequest = trimPullRequest(pullRequest, true); - - if (!trimmedPullRequest) { - return { - content: [{ type: "text", text: noDataErrorMessage }], - isError: true, - }; - } - - return { - content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error creating pull request: ${errorMessage}` }], - isError: true, - }; - } - } - ); - server.tool( - REPO_TOOLS.create_branch, - "Create a new branch in the repository.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the branch will be created. When using a repository name instead of a GUID, the project parameter must also be provided."), - branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."), - sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."), - sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - }, - async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + if (action === "get") { + if (!project) return { content: [{ type: "text", text: "project is required for get" }], isError: true }; + if (!repositoryNameOrId) return { content: [{ type: "text", text: "repositoryNameOrId is required for get" }], isError: true }; - let commitId = sourceCommitId; + const repositories = await gitApi.getRepositories(project); + const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId); - // If no commit ID is provided, get the latest commit from the source branch - if (!commitId) { - const sourceRefName = `refs/heads/${sourceBranchName}`; - try { - const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName); - const branch = sourceBranch.find((b) => b.name === sourceRefName); - if (!branch || !branch.objectId) { - return { - content: [ - { - type: "text", - text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}`, - }, - ], - isError: true, - }; - } - commitId = branch.objectId; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; + if (!repository) { + return { content: [{ type: "text", text: `Repository ${repositoryNameOrId} not found in project ${project}` }], isError: true }; } + return { content: [{ type: "text", text: JSON.stringify(repository, null, 2) }] }; } - // Create the new branch using updateRefs - const newRefName = `refs/heads/${branchName}`; - const refUpdate = { - name: newRefName, - newObjectId: commitId, - oldObjectId: "0000000000000000000000000000000000000000", // All zeros indicates creating a new ref - }; - - try { - const result = await gitApi.updateRefs([refUpdate], repositoryId, project); + if (action === "list") { + if (!project) return { content: [{ type: "text", text: "project is required for list" }], isError: true }; + + const repositories = await gitApi.getRepositories(project, false, false, false); + const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories; + const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top); + + const trimmedRepositories = paginatedRepositories?.map((repo) => ({ + id: repo.id, + name: repo.name, + isDisabled: repo.isDisabled, + isFork: repo.isFork, + isInMaintenance: repo.isInMaintenance, + webUrl: repo.webUrl, + size: repo.size, + })); - // Check if the branch creation was successful - if (result && result.length > 0 && result[0].success) { - return { - content: [ - { - type: "text", - text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})`, - }, - ], - }; - } else { - const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation"; - return { - content: [ - { - type: "text", - text: `Error creating branch '${branchName}': ${errorMessage}`, - }, - ], - isError: true, - }; - } - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; + return { content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }] }; } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error creating branch: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with repository operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_pull_request ----------------------------------------------------- server.tool( - REPO_TOOLS.update_pull_request, - "Update a Pull Request by ID with specified fields, including setting autocomplete with various completion options.", + REPO_TOOLS.repo_pull_request, + "Retrieve pull request data. Use the action parameter to specify the operation.", { - repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - title: z.string().optional().describe("The new title for the pull request."), - description: z.string().max(4000).optional().describe("The new description for the pull request. Must not be longer than 4000 characters."), - isDraft: z.boolean().optional().describe("Whether the pull request should be a draft."), - targetRefName: z.string().optional().describe("The new target branch name (e.g., 'refs/heads/main')."), - status: z.enum(["Active", "Abandoned"]).optional().describe("The new status of the pull request. Can be 'Active' or 'Abandoned'."), - autoComplete: z.boolean().optional().describe("Set the pull request to autocomplete when all requirements are met."), - mergeStrategy: z - .enum(getEnumKeys(GitPullRequestMergeStrategy) as [string, ...string[]]) + action: z + .enum(["get", "list", "list_by_commits"]) + .describe( + "The action to perform. Options: get (get a pull request by ID), list (list pull requests in a repository or project), list_by_commits (find pull requests that contain specific commit IDs)." + ), + repositoryId: z.string().optional().describe("The ID or name of the repository. Required for get. Optional for list. When using a name instead of a GUID, project must also be provided."), + pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for get."), + project: z.string().optional().describe("Project ID or project name. Required for list_by_commits. Optional for get and list."), + includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to include work item references. Used for get."), + includeLabels: z.boolean().optional().default(false).describe("Whether to include labels. Used for get."), + includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of changed files. Used for get."), + top: z.coerce.number().default(100).describe("The maximum number of pull requests to return. Used for list. Defaults to 100."), + skip: z.coerce.number().default(0).describe("The number of pull requests to skip. Used for list. Defaults to 0."), + created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user. Used for list."), + created_by_user: z.string().optional().describe("Filter pull requests created by a specific user email. Used for list."), + i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer. Used for list."), + user_is_reviewer: z.string().optional().describe("Filter pull requests where a specific user is a reviewer (email). Used for list."), + status: z + .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]]) + .default("Active") + .describe("Filter pull requests by status. Used for list. Defaults to 'Active'."), + sourceRefName: z.string().optional().describe("Filter by source branch. Used for list."), + targetRefName: z.string().optional().describe("Filter by target branch. Used for list and create."), + repository: z.string().optional().describe("Repository name or ID. Required for list_by_commits."), + commits: z.array(z.string()).optional().describe("Array of commit IDs to query. Required for list_by_commits."), + queryType: z + .enum(Object.values(GitPullRequestQueryType).filter((v): v is string => typeof v === "string") as [string, ...string[]]) .optional() - .describe("The merge strategy to use when the pull request autocompletes. Defaults to 'NoFastForward'."), - mergeCommitMessage: z.string().optional().describe("Commit message to use when the pull request is completed."), - deleteSourceBranch: z.boolean().optional().default(false).describe("Whether to delete the source branch when the pull request autocompletes. Defaults to false."), - transitionWorkItems: z.boolean().optional().default(true).describe("Whether to transition associated work items to the next state when the pull request autocompletes. Defaults to true."), - bypassReason: z.string().optional().describe("Reason for bypassing branch policies. When provided, branch policies will be automatically bypassed during autocompletion."), - labels: z.array(z.string()).optional().describe("Array of label names to replace existing labels on the pull request. This will remove all current labels and add the specified ones."), + .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit]) + .describe("Type of commit query. Used for list_by_commits."), }, async ({ + action, repositoryId, pullRequestId, project, - title, - description, - isDraft, - targetRefName, + includeWorkItemRefs, + includeLabels, + includeChangedFiles, + top, + skip, + created_by_me, + created_by_user, + i_am_reviewer, + user_is_reviewer, status, - autoComplete, - mergeStrategy, - mergeCommitMessage, - deleteSourceBranch, - transitionWorkItems, - bypassReason, - labels, + sourceRefName, + targetRefName, + repository, + commits, + queryType, }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - // Build update object with only provided fields - const updateRequest: Record = {}; - - if (title !== undefined) updateRequest.title = title; - if (description !== undefined) updateRequest.description = description; - if (isDraft !== undefined) updateRequest.isDraft = isDraft; - if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName; - if (status !== undefined) { - updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf(); - } - - if (autoComplete !== undefined) { - if (autoComplete) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const autoCompleteUserId = data.authenticatedUser.id; - updateRequest.autoCompleteSetBy = { id: autoCompleteUserId }; + if (action === "get") { + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for get" }], isError: true }; + if (!pullRequestId) return { content: [{ type: "text", text: "pullRequestId is required for get" }], isError: true }; - const completionOptions: GitPullRequestCompletionOptions = { - deleteSourceBranch: deleteSourceBranch || false, - transitionWorkItems: transitionWorkItems !== false, // Default to true unless explicitly set to false - bypassPolicy: !!bypassReason, // Automatically set to true if bypassReason is provided - }; + const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs); + let enhancedResponse: Record = { ...pullRequest }; - if (mergeStrategy) { - completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy as keyof typeof GitPullRequestMergeStrategy]; + if (includeLabels) { + try { + const projectId = pullRequest.repository?.project?.id; + const projectName = pullRequest.repository?.project?.name; + const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId); + const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined); + enhancedResponse = { ...enhancedResponse, labelSummary: { labels: labelNames, labelCount: labelNames.length } }; + } catch (error) { + console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`); + enhancedResponse = { ...enhancedResponse, labelSummary: {} }; } + } - if (mergeCommitMessage) { - completionOptions.mergeCommitMessage = mergeCommitMessage; + if (includeChangedFiles) { + try { + const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project); + if (iterations?.length) { + const latestIteration = iterations[iterations.length - 1]; + if (latestIteration.id != null) { + const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project); + enhancedResponse = { + ...enhancedResponse, + changedFilesSummary: { + changeEntries: changes?.changeEntries ?? [], + fileCount: changes?.changeEntries?.length ?? 0, + nextSkip: changes?.nextSkip, + nextTop: changes?.nextTop, + }, + }; + } else { + enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } }; + } + } else { + enhancedResponse = { ...enhancedResponse, changedFilesSummary: { changeEntries: [], fileCount: 0 } }; + } + } catch (error) { + console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`); + enhancedResponse = { ...enhancedResponse, changedFilesSummary: {} }; } + } - if (bypassReason) { - completionOptions.bypassReason = bypassReason; - } + return { content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }] }; + } - updateRequest.completionOptions = completionOptions; - } else { - updateRequest.autoCompleteSetBy = null; - updateRequest.completionOptions = null; + if (action === "list") { + if (!repositoryId && !project) { + return { content: [{ type: "text", text: "Either repositoryId or project must be provided." }], isError: true }; } - } - // Validate that at least one field is provided for update - if (Object.keys(updateRequest).length === 0 && !labels) { - return { - content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, autoComplete options, or labels) must be provided for update." }], - isError: true, - }; - } + const searchCriteria: { + status: number; + repositoryId?: string; + creatorId?: string; + reviewerId?: string; + sourceRefName?: string; + targetRefName?: string; + } = { status: pullRequestStatusStringToInt(status) }; + + if (repositoryId) searchCriteria.repositoryId = repositoryId; + if (sourceRefName) searchCriteria.sourceRefName = sourceRefName; + if (targetRefName) searchCriteria.targetRefName = targetRefName; + + if (created_by_user) { + try { + const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.creatorId = userId; + } catch (error) { + return { content: [{ type: "text", text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; + } + } else if (created_by_me) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.creatorId = data.authenticatedUser.id; + } - // Update labels if provided - if (labels) { - const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project); - for (const currentLabel of currentLabels) { - if (currentLabel.id) { - await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project); + if (user_is_reviewer) { + try { + const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.reviewerId = reviewerUserId; + } catch (error) { + return { content: [{ type: "text", text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } + } else if (i_am_reviewer) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + searchCriteria.reviewerId = data.authenticatedUser.id; } - for (const label of labels) { - await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project); + + let pullRequests; + /* istanbul ignore else */ + if (repositoryId) { + pullRequests = await gitApi.getPullRequests(repositoryId, searchCriteria, project, undefined, skip, top); + } else if (project) { + pullRequests = await gitApi.getPullRequestsByProject(project, searchCriteria, undefined, skip, top); } - } - let updatedPullRequest; - if (Object.keys(updateRequest).length > 0) { - updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project); - } else { - // If only labels were updated, get the current pull request - updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project); + const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr)); + return { content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }] }; } - const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true); + if (action === "list_by_commits") { + if (!project) return { content: [{ type: "text", text: "project is required for list_by_commits" }], isError: true }; + if (!repository) return { content: [{ type: "text", text: "repository is required for list_by_commits" }], isError: true }; + if (!commits || commits.length === 0) return { content: [{ type: "text", text: "commits is required for list_by_commits" }], isError: true }; - if (!trimmedUpdatedPullRequest) { - return { - content: [{ type: "text", text: "Pull request updated but API returned no data." }], + const query: GitPullRequestQuery = { + queries: [ + { + items: commits, + type: GitPullRequestQueryType[queryType as keyof typeof GitPullRequestQueryType], + } as GitPullRequestQueryInput, + ], }; + + const queryResult = await gitApi.getPullRequestQuery(query, repository, project); + return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] }; } - return { - content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error updating pull request: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with pull request operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_pull_request_thread ---------------------------------------------- server.tool( - REPO_TOOLS.update_pull_request_reviewers, - "Add or remove reviewers for an existing pull request.", + REPO_TOOLS.repo_pull_request_thread, + "Retrieve pull request thread and comment data. Use the action parameter to specify the operation.", { - repositoryId: z.string().describe("The ID or name of the repository where the pull request exists. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to update."), - reviewerIds: z.array(z.string()).describe("List of reviewer ids to add or remove from the pull request."), - action: z.enum(["add", "remove"]).describe("Action to perform on the reviewers. Can be 'add' or 'remove'."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), + action: z.enum(["list", "list_comments"]).describe("The action to perform. Options: list (list comment threads on a pull request), list_comments (list comments in a specific thread)."), + repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."), + pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."), + project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."), + threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for list_comments."), + iteration: z.coerce.number().min(1).optional().describe("The iteration ID. Used for list."), + baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID. Used for list."), + top: z.coerce.number().default(100).describe("The maximum number of results to return. Defaults to 100."), + skip: z.coerce.number().default(0).describe("The number of results to skip. Defaults to 0."), + fullResponse: z.boolean().optional().default(false).describe("Return full JSON response instead of trimmed data."), + status: z + .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) + .optional() + .describe("Filter threads by status. Used for list."), + authorEmail: z.string().optional().describe("Filter threads by the email of the thread author. Used for list."), + authorDisplayName: z.string().optional().describe("Filter threads by the display name of the thread author. Used for list."), }, - async ({ repositoryId, pullRequestId, reviewerIds, action, project }) => { + async ({ action, repositoryId, pullRequestId, project, threadId, iteration, baseIteration, top, skip, fullResponse, status, authorEmail, authorDisplayName }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - let updatedPullRequest; - if (action === "add") { - updatedPullRequest = await gitApi.createPullRequestReviewers( - reviewerIds.map((id) => ({ id: id })), - repositoryId, - pullRequestId, - project - ); + if (action === "list") { + const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? []; + let filteredThreads = threads; - const trimmedResponse = updatedPullRequest.map((item) => ({ - displayName: item.displayName, - id: item.id, - uniqueName: item.uniqueName, - vote: item.vote, - hasDeclined: item.hasDeclined, - isFlagged: item.isFlagged, - })); + if (status !== undefined) { + const statusValue = CommentThreadStatus[status as keyof typeof CommentThreadStatus]; + filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue); + } + if (authorEmail !== undefined) { + filteredThreads = filteredThreads.filter((thread) => { + const firstComment = thread.comments?.[0]; + return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase(); + }); + } + if (authorDisplayName !== undefined) { + const lowerAuthorName = authorDisplayName.toLowerCase(); + filteredThreads = filteredThreads.filter((thread) => { + const firstComment = thread.comments?.[0]; + return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName); + }); + } - return { - content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }], - }; - } else { - for (const reviewerId of reviewerIds) { - await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project); + const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); + + if (fullResponse) { + return { content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }] }; } - return { - content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }], - }; + const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread)); + return { content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }] }; + } + + if (action === "list_comments") { + if (!threadId) return { content: [{ type: "text", text: "threadId is required for list_comments" }], isError: true }; + + const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project); + const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); + + if (fullResponse) { + return { content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }] }; + } + + const trimmedComments = trimComments(paginatedComments); + return { content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }] }; } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error updating pull request reviewers: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with pull request thread operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_branch ----------------------------------------------------------- server.tool( - REPO_TOOLS.list_repos_by_project, - "Retrieve a list of repositories for a given project", + REPO_TOOLS.repo_branch, + "Retrieve branch data for a repository. Use the action parameter to specify the operation.", { - project: z.string().describe("The name or ID of the Azure DevOps project."), - top: z.coerce.number().default(100).describe("The maximum number of repositories to return."), - skip: z.coerce.number().default(0).describe("The number of repositories to skip. Defaults to 0."), - repoNameFilter: z.string().optional().describe("Optional filter to search for repositories by name. If provided, only repositories with names containing this string will be returned."), + action: z + .enum(["get", "list", "list_mine"]) + .describe("The action to perform. Options: get (get a branch by name), list (list branches in a repository), list_mine (list branches the current user has pushed to)."), + repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."), + project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."), + branchName: z.string().optional().describe("The name of the branch. Required for get."), + top: z.coerce.number().default(100).describe("The maximum number of branches to return. Used for list and list_mine. Defaults to 100."), + filterContains: z.string().optional().describe("Filter branches containing this string. Used for list and list_mine."), }, - async ({ project, top, skip, repoNameFilter }) => { + async ({ action, repositoryId, project, branchName, top, filterContains }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - const repositories = await gitApi.getRepositories(project, false, false, false); - const filteredRepositories = repoNameFilter ? filterReposByName(repositories, repoNameFilter) : repositories; + if (action === "get") { + if (!branchName) return { content: [{ type: "text", text: "branchName is required for get" }], isError: true }; - const paginatedRepositories = filteredRepositories?.sort((a, b) => a.name?.localeCompare(b.name ?? "") ?? 0).slice(skip, skip + top); + const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName); + const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName); - // Filter out the irrelevant properties - const trimmedRepositories = paginatedRepositories?.map((repo) => ({ - id: repo.id, - name: repo.name, - isDisabled: repo.isDisabled, - isFork: repo.isFork, - isInMaintenance: repo.isInMaintenance, - webUrl: repo.webUrl, - size: repo.size, - })); + if (!branch) { + return { content: [{ type: "text", text: `Branch ${branchName} not found in repository ${repositoryId}` }], isError: true }; + } + return { content: [{ type: "text", text: JSON.stringify(branch, null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(trimmedRepositories, null, 2) }], - }; + if (action === "list") { + const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains); + const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); + return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] }; + } + + if (action === "list_mine") { + const branches = await gitApi.getRefs(repositoryId, project, undefined, undefined, undefined, true, undefined, undefined, filterContains); + const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); + return { content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }] }; + } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error listing repositories: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with branch operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_file ------------------------------------------------------------- + const fileVersionTypeStrings = getEnumKeys(GitVersionType); + server.tool( - REPO_TOOLS.list_pull_requests_by_repo_or_project, - "Retrieve a list of pull requests for a given repository. Either repositoryId or project must be provided.", + REPO_TOOLS.repo_file, + "Retrieve file data from a repository. Use the action parameter to specify the operation.", { - repositoryId: z - .string() - .optional() - .describe("The ID or name of the repository where the pull requests are located. When using a repository name instead of a GUID, the project parameter must also be provided."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID, or to scope the search to a specific project."), - top: z.coerce.number().default(100).describe("The maximum number of pull requests to return."), - skip: z.coerce.number().default(0).describe("The number of pull requests to skip."), - created_by_me: z.boolean().default(false).describe("Filter pull requests created by the current user."), - created_by_user: z.string().optional().describe("Filter pull requests created by a specific user (provide email or unique name). Takes precedence over created_by_me if both are provided."), - i_am_reviewer: z.boolean().default(false).describe("Filter pull requests where the current user is a reviewer."), - user_is_reviewer: z - .string() + action: z + .enum(["get_content", "list_directory"]) + .describe("The action to perform. Options: get_content (get the text content of a file at a specific branch, tag, or commit), list_directory (list files and folders in a directory)."), + repositoryId: z.string().describe("The ID or name of the repository."), + path: z.string().optional().default("/").describe("The file or directory path. Required for get_content. Defaults to '/' for list_directory."), + project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."), + version: z.string().optional().describe("Version string: branch name, tag name, or commit SHA."), + versionType: z + .enum(fileVersionTypeStrings as [string, ...string[]]) .optional() - .describe("Filter pull requests where a specific user is a reviewer (provide email or unique name). Takes precedence over i_am_reviewer if both are provided."), - status: z - .enum(getEnumKeys(PullRequestStatus) as [string, ...string[]]) - .default("Active") - .describe("Filter pull requests by status. Defaults to 'Active'."), - sourceRefName: z.string().optional().describe("Filter pull requests from this source branch (e.g., 'refs/heads/feature-branch')."), - targetRefName: z.string().optional().describe("Filter pull requests into this target branch (e.g., 'refs/heads/main')."), + .default("Commit") + .describe("How to interpret the version parameter. Used for get_content. Defaults to 'Commit'."), + recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Used for list_directory. Defaults to false."), + recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing. Used for list_directory when recursive is true. Defaults to 1."), }, - async ({ repositoryId, project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => { + async ({ action, repositoryId, path, project, version, versionType, recursive, recursionDepth }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - // Build the search criteria - const searchCriteria: { - status: number; - repositoryId?: string; - creatorId?: string; - reviewerId?: string; - sourceRefName?: string; - targetRefName?: string; - } = { - status: pullRequestStatusStringToInt(status), - }; + if (action === "get_content") { + if (!path) return { content: [{ type: "text", text: "path is required for get_content" }], isError: true }; - if (!repositoryId && !project) { - return { - content: [ - { - type: "text", - text: "Either repositoryId or project must be provided.", - }, - ], - isError: true, - }; - } + const versionDescriptor: GitVersionDescriptor | undefined = version ? { version, versionType: GitVersionType[versionType as keyof typeof GitVersionType] } : undefined; - if (repositoryId) { - searchCriteria.repositoryId = repositoryId; - } + const stream = await gitApi.getItemText(repositoryId, path, project, undefined, undefined, undefined, undefined, false, versionDescriptor, true); + const content = await streamToString(stream); - if (sourceRefName) { - searchCriteria.sourceRefName = sourceRefName; - } + const streamError = extractAdoStreamError(content); + if (streamError) { + return { content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }], isError: true }; + } - if (targetRefName) { - searchCriteria.targetRefName = targetRefName; + return { content: [{ type: "text", text: content }] }; } - if (created_by_user) { - try { - const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider); - searchCriteria.creatorId = userId; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error finding user with email ${created_by_user}: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; + if (action === "list_directory") { + const versionDescriptor = buildVersionDescriptor(version, versionType === "Commit" ? "Branch" : versionType); + const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10); + const recursionType = recursive ? VersionControlRecursionType.Full : VersionControlRecursionType.OneLevel; + + const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor); + + if (!items || items.length === 0) { + return { content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }], isError: true }; } - } else if (created_by_me) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const userId = data.authenticatedUser.id; - searchCriteria.creatorId = userId; - } - if (user_is_reviewer) { - try { - const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider); - searchCriteria.reviewerId = reviewerUserId; - } catch (error) { - return { - content: [ - { - type: "text", - text: `Error finding reviewer with email ${user_is_reviewer}: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - isError: true, - }; + let filteredItems = items; + + if (recursive && clampedDepth < 10) { + const basePath = path === "/" ? "" : path; + const baseDepth = basePath.split("/").filter((p) => p).length; + filteredItems = items.filter((item) => { + if (!item.path) return false; + const itemDepth = item.path.split("/").filter((p) => p).length; + return itemDepth <= baseDepth + clampedDepth; + }); } - } else if (i_am_reviewer) { - const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const userId = data.authenticatedUser.id; - searchCriteria.reviewerId = userId; - } - let pullRequests; - if (repositoryId) { - pullRequests = await gitApi.getPullRequests( - repositoryId, - searchCriteria, - project, // project - undefined, // maxCommentLength - skip, - top - ); - } else if (project) { - // If only project is provided, use getPullRequestsByProject - pullRequests = await gitApi.getPullRequestsByProject( - project, - searchCriteria, - undefined, // maxCommentLength - skip, - top - ); - } else { - // This case should not occur due to earlier validation, but added for completeness + const formattedItems = filteredItems.map((item) => ({ + path: item.path, + isFolder: item.isFolder, + gitObjectType: item.gitObjectType, + commitId: item.commitId, + contentMetadata: item.contentMetadata ? { contentType: item.contentMetadata.contentType, fileName: item.contentMetadata.fileName } : undefined, + })); + return { content: [ { type: "text", - text: "Either repositoryId or project must be provided.", + text: JSON.stringify({ count: formattedItems.length, path, recursive, recursionDepth: recursive ? clampedDepth : undefined, items: formattedItems }, null, 2), }, ], - isError: true, }; } - const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr)); - - return { - content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error listing pull requests: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with file operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_search_commits --------------------------------------------------- server.tool( - REPO_TOOLS.list_pull_request_threads, - "Retrieve a list of comment threads for a pull request.", + REPO_TOOLS.repo_search_commits, + "Search commits with filtering by text, author, date range, and more.", { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve threads."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - iteration: z.coerce.number().min(1).optional().describe("The iteration ID for which to retrieve threads. Optional, defaults to the latest iteration."), - baseIteration: z.coerce.number().min(1).optional().describe("The base iteration ID for which to retrieve threads. Optional, defaults to the latest base iteration."), - top: z.coerce.number().default(100).describe("The maximum number of threads to return after filtering."), - skip: z.coerce.number().default(0).describe("The number of threads to skip after filtering."), - fullResponse: z.boolean().optional().default(false).describe("Return full thread JSON response instead of trimmed data."), - status: z - .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) + searchText: z.string().describe("Keywords to search for in commit messages"), + project: z + .union([z.string().transform(/* istanbul ignore next */ (value) => [value]), z.array(z.string())]) .optional() - .describe("Filter threads by status. If not specified, returns threads of all statuses."), - authorEmail: z.string().optional().describe("Filter threads by the email of the thread author (first comment author)."), - authorDisplayName: z.string().optional().describe("Filter threads by the display name of the thread author (first comment author). Case-insensitive partial matching."), + .describe("The names of the projects to search within. If omitted, searches across all projects in the organization."), + repository: z.array(z.string()).optional().describe("The names of the repositories to search within."), + branch: z.array(z.string()).optional().describe("The names of the repository branches to search within."), + author: z.array(z.string()).optional().describe("The names of the commit authors to search for. Only full display names are supported."), + commitStartDate: z.string().optional().describe("Filter commits from this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"), + commitEndDate: z.string().optional().describe("Filter commits up to this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"), + orderBy: z.enum(["ASC", "DESC"]).optional().describe("Sort commits by date: 'ASC' for oldest-first, 'DESC' for newest-first."), + includeFacets: z.boolean().default(false).describe("Include facets in the search results"), + skip: z.coerce.number().default(0).describe("Number of results to skip"), + top: z.coerce.number().default(10).describe("Maximum number of results to return"), }, - async ({ repositoryId, pullRequestId, project, iteration, baseIteration, top, skip, fullResponse, status, authorEmail, authorDisplayName }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - const threads = (await gitApi.getThreads(repositoryId, pullRequestId, project, iteration, baseIteration)) ?? []; + async ({ searchText, project, repository, branch, author, commitStartDate, commitEndDate, orderBy, includeFacets, skip, top }) => { + const accessToken = await tokenProvider(); + const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/commitSearchResults?api-version=${apiVersion}`; - let filteredThreads = threads; + const requestBody: Record = { searchText, includeFacets, $skip: skip, $top: top }; - if (status !== undefined) { - const statusValue = CommentThreadStatus[status as keyof typeof CommentThreadStatus]; - filteredThreads = filteredThreads.filter((thread) => thread.status === statusValue); - } - - if (authorEmail !== undefined) { - filteredThreads = filteredThreads.filter((thread) => { - const firstComment = thread.comments?.[0]; - return firstComment?.author?.uniqueName?.toLowerCase() === authorEmail.toLowerCase(); - }); - } - - if (authorDisplayName !== undefined) { - const lowerAuthorName = authorDisplayName.toLowerCase(); - filteredThreads = filteredThreads.filter((thread) => { - const firstComment = thread.comments?.[0]; - return firstComment?.author?.displayName?.toLowerCase().includes(lowerAuthorName); - }); - } - - const paginatedThreads = filteredThreads.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); - - if (fullResponse) { - return { - content: [{ type: "text", text: JSON.stringify(paginatedThreads, null, 2) }], - }; - } - - // Return trimmed thread data focusing on essential information - const trimmedThreads = paginatedThreads.map((thread) => trimPullRequestThread(thread)); + const filters: Record = {}; + if (project && project.length > 0) filters.projectName = project; + if (repository && repository.length > 0) filters.repositoryName = repository; + if (branch && branch.length > 0) filters.branchName = branch; + if (author && author.length > 0) filters.authorName = author; + if (commitStartDate) filters.commitStartDate = [commitStartDate]; + if (commitEndDate) filters.commitEndDate = [commitEndDate]; - return { - content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + requestBody.filters = filters; - return { - content: [{ type: "text", text: `Error listing pull request threads: ${errorMessage}` }], - isError: true, - }; + if (orderBy) { + requestBody.$orderBy = [{ field: "commitDate", sortOrder: orderBy }]; } - } - ); - - server.tool( - REPO_TOOLS.list_pull_request_thread_comments, - "Retrieve a list of comments in a pull request thread.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request for which to retrieve thread comments."), - threadId: z.coerce.number().min(1).describe("The ID of the thread for which to retrieve comments."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - top: z.coerce.number().default(100).describe("The maximum number of comments to return."), - skip: z.coerce.number().default(0).describe("The number of comments to skip."), - fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of trimmed data."), - }, - async ({ repositoryId, pullRequestId, threadId, project, top, skip, fullResponse }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - // Get thread comments - GitApi uses getComments for retrieving comments from a specific thread - const comments = await gitApi.getComments(repositoryId, pullRequestId, threadId, project); - - const paginatedComments = comments?.sort((a, b) => (a.id ?? 0) - (b.id ?? 0)).slice(skip, skip + top); - if (fullResponse) { - return { - content: [{ type: "text", text: JSON.stringify(paginatedComments, null, 2) }], - }; - } - - // Return trimmed comment data focusing on essential information - const trimmedComments = trimComments(paginatedComments); - - return { - content: [{ type: "text", text: JSON.stringify(trimmedComments, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(requestBody), + }); - return { - content: [{ type: "text", text: `Error listing pull request thread comments: ${errorMessage}` }], - isError: true, - }; + if (!response.ok) { + throw new Error(`Azure DevOps Commit Search API error: ${response.status} ${response.statusText}`); } - } - ); - - server.tool( - REPO_TOOLS.list_branches_by_repo, - "Retrieve a list of branch names for a given repository. Returns an array of branch name strings, not full branch objects. Use repo_get_branch_by_name to get full details for a specific branch.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."), - top: z.coerce.number().default(100).describe("The maximum number of branches to return. Defaults to 100."), - filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - }, - async ({ repositoryId, top, filterContains, project }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, undefined, undefined, undefined, filterContains); - const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); - - return { - content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error listing branches: ${errorMessage}` }], - isError: true, - }; - } + const result = await response.text(); + return { content: [{ type: "text", text: result }] }; } ); + // --- repo_pull_request_write ----------------------------------------------- server.tool( - REPO_TOOLS.list_my_branches_by_repo, - "Retrieve a list of my branch names for a given repository Id. Returns an array of branch name strings, not full branch objects. Use repo_get_branch_by_name to get full details for a specific branch.", + REPO_TOOLS.repo_pull_request_write, + "Write operations for pull requests. Use the action parameter to specify the operation.", { - repositoryId: z - .string() - .describe("The ID or name of the repository where the branches are located. When using a repository name instead of a GUID, the project parameter must also be provided."), - top: z.coerce.number().default(100).describe("The maximum number of branches to return."), - filterContains: z.string().optional().describe("Filter to find branches that contain this string in their name."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), + action: z + .enum(["create", "update", "update_reviewers", "vote"]) + .describe( + "The action to perform. Options: create (create a pull request), update (update a pull request, including setting autocomplete), update_reviewers (add or remove pull request reviewers), vote (cast a vote on a pull request)." + ), + repositoryId: z.string().optional().describe("The ID or name of the repository. Required for all actions. When using a name instead of a GUID, project must also be provided."), + pullRequestId: z.coerce.number().min(1).optional().describe("The ID of the pull request. Required for update, update_reviewers, and vote."), + project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."), + sourceRefName: z.string().optional().describe("The source branch name (e.g., 'refs/heads/feature-branch'). Required for create."), + targetRefName: z.string().optional().describe("The target branch name (e.g., 'refs/heads/main'). Required for create. Optional for update."), + title: z.string().optional().describe("The title of the pull request. Required for create. Optional for update."), + description: z.string().max(4000).optional().describe("The description of the pull request. Max 4000 characters. Used for create and update."), + isDraft: z.boolean().optional().default(false).describe("Whether the pull request is a draft. Used for create and update."), + workItems: z.string().optional().describe("Work item IDs to associate, space-separated. Used for create."), + forkSourceRepositoryId: z.string().optional().describe("The ID of the fork repository. Used for create."), + labels: z.array(z.string()).optional().describe("Array of label names. Used for create and update."), + status: z.enum(["Active", "Abandoned"]).optional().describe("The new status. Used for update."), + autoComplete: z.boolean().optional().describe("Set autocomplete when all requirements are met. Used for update."), + mergeStrategy: z + .enum(getEnumKeys(GitPullRequestMergeStrategy) as [string, ...string[]]) + .optional() + .describe("The merge strategy for autocomplete. Used for update."), + mergeCommitMessage: z.string().optional().describe("Commit message for autocomplete. Used for update."), + deleteSourceBranch: z.boolean().optional().default(false).describe("Delete source branch on autocomplete. Used for update."), + transitionWorkItems: z.boolean().optional().default(true).describe("Transition work items on autocomplete. Used for update."), + bypassReason: z.string().optional().describe("Reason for bypassing branch policies. Used for update."), + reviewerIds: z.array(z.string()).optional().describe("List of reviewer IDs. Required for update_reviewers."), + reviewerAction: z.enum(["add", "remove"]).optional().describe("Whether to add or remove reviewers. Required for update_reviewers."), + vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).optional().describe("The vote to cast. Required for vote."), }, - async ({ repositoryId, top, filterContains, project }) => { + async ({ + action, + repositoryId, + pullRequestId, + project, + sourceRefName, + targetRefName, + title, + description, + isDraft, + workItems, + forkSourceRepositoryId, + labels, + status, + autoComplete, + mergeStrategy, + mergeCommitMessage, + deleteSourceBranch, + transitionWorkItems, + bypassReason, + reviewerIds, + reviewerAction, + vote, + }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, project, "heads/", undefined, undefined, true, undefined, undefined, filterContains); - - const filteredBranches = branchesFilterOutIrrelevantProperties(branches, top); - - return { - content: [{ type: "text", text: JSON.stringify(filteredBranches, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error listing my branches: ${errorMessage}` }], - isError: true, - }; - } - } - ); - server.tool( - REPO_TOOLS.get_repo_by_name_or_id, - "Get the repository by project and repository name or ID.", - { - project: z.string().describe("Project name or ID where the repository is located."), - repositoryNameOrId: z.string().describe("Repository name or ID."), - }, - async ({ project, repositoryNameOrId }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const repositories = await gitApi.getRepositories(project); + if (action === "create") { + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for create" }], isError: true }; + if (!sourceRefName) return { content: [{ type: "text", text: "sourceRefName is required for create" }], isError: true }; + if (!targetRefName) return { content: [{ type: "text", text: "targetRefName is required for create" }], isError: true }; + if (!title) return { content: [{ type: "text", text: "title is required for create" }], isError: true }; - const repository = repositories?.find((repo) => repo.name === repositoryNameOrId || repo.id === repositoryNameOrId); + const workItemRefs = workItems ? workItems.split(" ").map((id) => ({ id: id.trim() })) : []; + const noDataErrorMessage = + `Pull request creation returned no data and no matching PR was found. This often means repositoryId="${repositoryId}" was not resolvable. ` + + "Try the repository GUID from repo_repository (list action) instead of the Project/RepoName slash format."; - if (!repository) { - return { - content: [{ type: "text", text: `Repository ${repositoryNameOrId} not found in project ${project}` }], - isError: true, - }; - } + const forkSource: GitForkRef | undefined = forkSourceRepositoryId ? { repository: { id: forkSourceRepositoryId } } : undefined; + const labelDefinitions: WebApiTagDefinition[] | undefined = labels ? labels.map((label) => ({ name: label })) : undefined; - return { - content: [{ type: "text", text: JSON.stringify(repository, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + let pullRequest = await gitApi.createPullRequest( + { sourceRefName, targetRefName, title, description, isDraft, workItemRefs, forkSource, labels: labelDefinitions, supportsIterations: true }, + repositoryId, + project + ); - return { - content: [{ type: "text", text: `Error getting repository: ${errorMessage}` }], - isError: true, - }; - } - } - ); + if (!pullRequest) { + const prs = await gitApi.getPullRequests(repositoryId, { sourceRefName, targetRefName, status: PullRequestStatus.Active }, project, undefined, 0, 1); + if (prs && prs.length > 0) { + pullRequest = prs[0]; + } else { + return { content: [{ type: "text", text: noDataErrorMessage }], isError: true }; + } + } - server.tool( - REPO_TOOLS.get_branch_by_name, - "Get a branch by its name. Returns isError: true if the branch is not found.", - { - repositoryId: z.string().describe("The ID or name of the repository where the branch is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - branchName: z.string().describe("The name of the branch to retrieve, e.g., 'main' or 'feature-branch'."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - }, - async ({ repositoryId, branchName, project }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const branches = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, branchName); - const branch = branches.find((branch) => branch.name === `refs/heads/${branchName}` || branch.name === branchName); - if (!branch) { - return { - content: [ - { - type: "text", - text: `Branch ${branchName} not found in repository ${repositoryId}`, - }, - ], - isError: true, - }; + const trimmedPullRequest = trimPullRequest(pullRequest, true); + return { content: [{ type: "text", text: JSON.stringify(trimmedPullRequest, null, 2) }] }; } - return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error getting branch: ${errorMessage}` }], - isError: true, - }; - } - } - ); + if (action === "update") { + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for update" }], isError: true }; + if (!pullRequestId) return { content: [{ type: "text", text: "pullRequestId is required for update" }], isError: true }; - server.tool( - REPO_TOOLS.get_pull_request_by_id, - "Get a pull request by its ID.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to retrieve."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - includeWorkItemRefs: z.boolean().optional().default(false).describe("Whether to reference work items associated with the pull request."), - includeLabels: z.boolean().optional().default(false).describe("Whether to include a summary of labels in the response."), - includeChangedFiles: z.boolean().optional().default(false).describe("Whether to include the list of files changed in the pull request."), - }, - async ({ repositoryId, pullRequestId, project, includeWorkItemRefs, includeLabels, includeChangedFiles }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const pullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project, undefined, undefined, undefined, undefined, includeWorkItemRefs); + const updateRequest: Record = {}; - let enhancedResponse: Record = { ...pullRequest }; + if (title !== undefined) updateRequest.title = title; + if (description !== undefined) updateRequest.description = description; + if (isDraft !== undefined) updateRequest.isDraft = isDraft; + if (targetRefName !== undefined) updateRequest.targetRefName = targetRefName; + if (status !== undefined) { + updateRequest.status = status === "Active" ? PullRequestStatus.Active.valueOf() : PullRequestStatus.Abandoned.valueOf(); + } - if (includeLabels) { - try { - const projectId = pullRequest.repository?.project?.id; - const projectName = pullRequest.repository?.project?.name; - const labels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, projectName, projectId); + if (autoComplete !== undefined) { + if (autoComplete) { + const data = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + updateRequest.autoCompleteSetBy = { id: data.authenticatedUser.id }; - const labelNames = labels.map((label) => label.name).filter((name) => name !== undefined); + const completionOptions: GitPullRequestCompletionOptions = { + deleteSourceBranch: deleteSourceBranch || false, + transitionWorkItems: transitionWorkItems !== false, + bypassPolicy: !!bypassReason, + }; - enhancedResponse = { - ...enhancedResponse, - labelSummary: { - labels: labelNames, - labelCount: labelNames.length, - }, - }; - } catch (error) { - console.warn(`Error fetching PR labels: ${error instanceof Error ? error.message : "Unknown error"}`); - enhancedResponse = { - ...enhancedResponse, - labelSummary: {}, - }; - } - } + if (mergeStrategy) completionOptions.mergeStrategy = GitPullRequestMergeStrategy[mergeStrategy as keyof typeof GitPullRequestMergeStrategy]; + if (mergeCommitMessage) completionOptions.mergeCommitMessage = mergeCommitMessage; + if (bypassReason) completionOptions.bypassReason = bypassReason; - if (includeChangedFiles) { - try { - const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project); - - if (iterations?.length) { - const latestIteration = iterations[iterations.length - 1]; - - if (latestIteration.id != null) { - const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, latestIteration.id, project); - - enhancedResponse = { - ...enhancedResponse, - changedFilesSummary: { - changeEntries: changes?.changeEntries ?? [], - fileCount: changes?.changeEntries?.length ?? 0, - nextSkip: changes?.nextSkip, - nextTop: changes?.nextTop, - }, - }; - } else { - enhancedResponse = { - ...enhancedResponse, - changedFilesSummary: { changeEntries: [], fileCount: 0 }, - }; - } + updateRequest.completionOptions = completionOptions; } else { - enhancedResponse = { - ...enhancedResponse, - changedFilesSummary: { changeEntries: [], fileCount: 0 }, - }; + updateRequest.autoCompleteSetBy = null; + updateRequest.completionOptions = null; } - } catch (error) { - console.warn(`Error fetching PR changed files: ${error instanceof Error ? error.message : "Unknown error"}`); - enhancedResponse = { - ...enhancedResponse, - changedFilesSummary: {}, - }; } - } - - return { - content: [{ type: "text", text: JSON.stringify(enhancedResponse, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error getting pull request: ${errorMessage}` }], - isError: true, - }; - } - } - ); - server.tool( - REPO_TOOLS.get_pull_request_changes, - "Get the file changes (diff) for a pull request iteration with actual code diff content. Returns the code changes including line-by-line diffs made in the pull request.", - { - repositoryId: z.string().describe("The ID of the repository where the pull request is located."), - pullRequestId: z.number().describe("The ID of the pull request to retrieve changes for."), - iterationId: z.number().optional().describe("The iteration ID to get changes for. If not specified, gets changes for the latest iteration."), - project: z.string().optional().describe("Project ID or project name (optional)"), - top: z.number().optional().describe("Maximum number of files to include diffs for. Default is 100."), - skip: z.number().optional().describe("Number of changes to skip for pagination."), - compareTo: z.number().optional().describe("Iteration ID to compare against. If specified, returns changes between two iterations."), - includeDiffs: z.boolean().optional().describe("Whether to include actual line-by-line diff content. Default is true. Set to false to get only file metadata."), - includeLineContent: z - .boolean() - .optional() - .describe( - "Whether to include the actual line content from the changed files. Default is true. When true, fetches file content and includes the actual code lines that were added/removed/modified." - ), - }, - async ({ repositoryId, pullRequestId, iterationId, project, top, skip, compareTo, includeDiffs = true, includeLineContent = true }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - // If repositoryId is a name (not a GUID), we need a project to resolve it. - // GUID pattern: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx - const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId); - if (!isGuid && !project) { - return { - content: [ - { - type: "text", - text: "Error: When using a repository name instead of a GUID for repositoryId, the 'project' parameter is required. Please either provide the project name/ID, or use repo_get_repo_by_name_or_id to resolve the repository GUID first.", - }, - ], - isError: true, - }; - } - - // If no iteration ID provided, get the latest iteration - let targetIterationId = iterationId; - let targetIteration; - if (targetIterationId == null) { - const iterations = await gitApi.getPullRequestIterations(repositoryId, pullRequestId, project); - if (!iterations || iterations.length === 0) { + if (Object.keys(updateRequest).length === 0 && !labels) { return { - content: [{ type: "text", text: "No iterations found for this pull request." }], + content: [{ type: "text", text: "Error: At least one field (title, description, isDraft, targetRefName, status, autoComplete options, or labels) must be provided for update." }], isError: true, }; } - // Get the latest iteration - targetIteration = iterations[iterations.length - 1]; - targetIterationId = targetIteration.id; - } else { - // Get the specific iteration - targetIteration = await gitApi.getPullRequestIteration(repositoryId, pullRequestId, targetIterationId, project); - } - - // Get the file change metadata - const changes = await gitApi.getPullRequestIterationChanges(repositoryId, pullRequestId, targetIterationId ?? 1, project, top, skip, compareTo); - - // If includeDiffs is false, just return the metadata - if (!includeDiffs) { - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } - - // Get actual diff content using getFileDiffs - if (changes.changeEntries && changes.changeEntries.length > 0 && targetIteration) { - // Determine base and target commits - const baseCommitId = compareTo - ? (await gitApi.getPullRequestIteration(repositoryId, pullRequestId, compareTo, project)).sourceRefCommit?.commitId - : targetIteration.commonRefCommit?.commitId; - const targetCommitId = targetIteration.sourceRefCommit?.commitId; - - if (baseCommitId && targetCommitId) { - // Build FileDiffsCriteria with paths from changeEntries - // Exclude added and deleted files as they don't have both versions to diff - // changeType is a flags enum so use bitwise AND to check - const fileDiffParams = changes.changeEntries - .filter((entry) => { - const ct = entry.changeType ?? 0; - return entry.item?.path && !(ct & VersionControlChangeType.Add) && !(ct & VersionControlChangeType.Delete); - }) - .map((entry) => { - // Remove leading slash if present - Azure DevOps API expects relative paths - const itemPath = entry.item?.path ?? ""; - const path = itemPath.startsWith("/") ? itemPath.substring(1) : itemPath; - // For renamed/moved files, use the original path from the change entry - const origPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : path; - return { - path: path, - originalPath: origPath, - }; - }); - try { - // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs - // because they don't have two versions to compare; their content is fetched - // separately below via getItemText when includeLineContent is true. - let fileDiffs: any[] = []; - if (fileDiffParams.length > 0) { - // Azure DevOps getFileDiffs API accepts max 10 files per request - const FILE_DIFF_BATCH_SIZE = 10; - for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) { - const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE); - const batchDiffs = await gitApi.getFileDiffs( - { - baseVersionCommit: baseCommitId, - targetVersionCommit: targetCommitId, - fileDiffParams: batch, - }, - project || "", - repositoryId - ); - fileDiffs = fileDiffs.concat(batchDiffs); - } - } + if (labels) { + const currentLabels = await gitApi.getPullRequestLabels(repositoryId, pullRequestId, project); + for (const currentLabel of currentLabels) { + if (currentLabel.id) await gitApi.deletePullRequestLabels(repositoryId, pullRequestId, currentLabel.id, project); + } + for (const label of labels) { + await gitApi.createPullRequestLabel({ name: label }, repositoryId, pullRequestId, project); + } + } - // Merge diff content with change metadata. - // Added/deleted entries get diff: null here and are enriched below. - const enrichedChanges = { - ...changes, - changeEntries: changes.changeEntries.map((entry) => { - // Normalize path for comparison (remove leading slash) - const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; - const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath); - return { - ...entry, - diff: matchingDiff || null, - }; - }), - }; + let updatedPullRequest; + if (Object.keys(updateRequest).length > 0) { + updatedPullRequest = await gitApi.updatePullRequest(updateRequest, repositoryId, pullRequestId, project); + } else { + updatedPullRequest = await gitApi.getPullRequest(repositoryId, pullRequestId, project); + } - // If includeLineContent is true, fetch actual file content with concurrency limit - if (includeLineContent && enrichedChanges.changeEntries) { - const CONCURRENCY_LIMIT = 10; - const entriesWithContent = [...enrichedChanges.changeEntries]; - for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) { - const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT); - const batchResults = await Promise.all( - batch.map(async (entry) => { - const ct = entry.changeType ?? 0; - const isAdd = !!(ct & VersionControlChangeType.Add); - const isDelete = !!(ct & VersionControlChangeType.Delete); - - const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined; - // For deleted files ADO sets item.path to null and puts the path in originalPath only. - // Normalise originalPath once and use it as the fallback throughout. - const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined; - // effectivePath is what we use as the "current" path for API calls / early-exit guard. - // For additions/modifications it's item.path; for deletions it's originalPath. - const effectivePath = entryPath ?? normalizedOriginalPath; - - if (!effectivePath) { - return entry; - } - - // Handle added files: fetch full content at target commit and create synthetic diff - if (isAdd && !entry.diff) { - try { - const targetStream = await gitApi - .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (targetStream) { - const targetText = await streamToString(targetStream); - const targetLines = targetText.split(/\r?\n/); - return { - ...entry, - diff: { - path: effectivePath, - originalPath: null, - lineDiffBlocks: [ - { - changeType: 1, // Add - originalLineNumberStart: 0, - originalLinesCount: 0, - modifiedLineNumberStart: 1, - modifiedLinesCount: targetLines.length, - modifiedLines: targetLines, - }, - ], - }, - }; - } - } catch (addError) { - return { - ...entry, - _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`, - }; - } - return entry; - } - - // Handle deleted files: fetch full content at base commit and create synthetic diff. - // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath. - if (isDelete && !entry.diff) { - try { - const basePath = normalizedOriginalPath ?? effectivePath; - const baseStream = await gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (baseStream) { - const baseText = await streamToString(baseStream); - const baseLines = baseText.split(/\r?\n/); - return { - ...entry, - diff: { - path: null, - originalPath: basePath, - lineDiffBlocks: [ - { - changeType: 2, // Delete - originalLineNumberStart: 1, - originalLinesCount: baseLines.length, - modifiedLineNumberStart: 0, - modifiedLinesCount: 0, - originalLines: baseLines, - }, - ], - }, - }; - } - } catch (delError) { - return { - ...entry, - _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`, - }; - } - return entry; - } - - // For modified/renamed files, skip if no diff blocks - if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) { - return entry; - } - - // For renamed/moved files, the base version is at the original path - const basePath = normalizedOriginalPath ?? effectivePath; - - try { - // Fetch file content at both commits - const [baseContent, targetContent] = await Promise.all([ - // Base version (original) - use basePath for renamed files - gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - // Target version (modified) - gitApi - .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - ]); - - // Convert streams to text - const baseText = baseContent ? await streamToString(baseContent) : ""; - const targetText = targetContent ? await streamToString(targetContent) : ""; - - // Check if response is an Azure DevOps error (returned as JSON in the stream) - const checkForApiError = (text: string, label: string) => { - if (text.startsWith("{")) { - try { - const parsed = JSON.parse(text); - if (parsed.$id && parsed.innerException !== undefined) { - throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Failed to fetch")) throw e; - // Not valid JSON or not an error response — treat as legitimate content - } - } - }; - checkForApiError(baseText, "base"); - checkForApiError(targetText, "target"); - - // Split into lines - const baseLines = baseText.split(/\r?\n/); - const targetLines = targetText.split(/\r?\n/); - - // Enrich each lineDiffBlock with actual line content - const enrichedDiff = { - ...entry.diff, - lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block: any) => { - const enrichedBlock: any = { ...block }; - - // Add original (base) lines if they exist - if (block.originalLineNumberStart && block.originalLinesCount) { - const startIdx = block.originalLineNumberStart - 1; - const endIdx = startIdx + block.originalLinesCount; - enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx); - } - - // Add modified (target) lines if they exist - if (block.modifiedLineNumberStart && block.modifiedLinesCount) { - const startIdx = block.modifiedLineNumberStart - 1; - const endIdx = startIdx + block.modifiedLinesCount; - enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx); - } - - return enrichedBlock; - }), - }; - - return { - ...entry, - diff: enrichedDiff, - }; - } catch (contentError) { - // If content fetch fails, return entry with error - return { - ...entry, - _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`, - }; - } - }) - ); - // Write batch results back into the array - for (let j = 0; j < batchResults.length; j++) { - entriesWithContent[i + j] = batchResults[j]; - } - } + const trimmedUpdatedPullRequest = trimPullRequest(updatedPullRequest, true); + if (!trimmedUpdatedPullRequest) { + return { content: [{ type: "text", text: "Pull request updated but API returned no data." }] }; + } - enrichedChanges.changeEntries = entriesWithContent; - } + return { content: [{ type: "text", text: JSON.stringify(trimmedUpdatedPullRequest, null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }], - }; - } catch (diffError) { - // If diff fetching fails, return metadata with error info - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - ...changes, - _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`, - _note: "Returned metadata only", - }, - null, - 2 - ), - }, - ], - }; + if (action === "update_reviewers") { + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for update_reviewers" }], isError: true }; + if (!pullRequestId) return { content: [{ type: "text", text: "pullRequestId is required for update_reviewers" }], isError: true }; + if (!reviewerIds || reviewerIds.length === 0) return { content: [{ type: "text", text: "reviewerIds is required for update_reviewers" }], isError: true }; + if (!reviewerAction) return { content: [{ type: "text", text: "reviewerAction is required for update_reviewers" }], isError: true }; + + if (reviewerAction === "add") { + const updatedReviewers = await gitApi.createPullRequestReviewers( + reviewerIds.map((id) => ({ id })), + repositoryId, + pullRequestId, + project + ); + + const trimmedResponse = updatedReviewers.map((item) => ({ + displayName: item.displayName, + id: item.id, + uniqueName: item.uniqueName, + vote: item.vote, + hasDeclined: item.hasDeclined, + isFlagged: item.isFlagged, + })); + + return { content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }] }; + } else { + for (const reviewerId of reviewerIds) { + await gitApi.deletePullRequestReviewer(repositoryId, pullRequestId, reviewerId, project); } + return { content: [{ type: "text", text: `Reviewers with IDs ${reviewerIds.join(", ")} removed from pull request ${pullRequestId}.` }] }; } } - // Fallback: return metadata if we couldn't get diffs - return { - content: [{ type: "text", text: JSON.stringify(changes, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + if (action === "vote") { + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for vote" }], isError: true }; + if (!pullRequestId) return { content: [{ type: "text", text: "pullRequestId is required for vote" }], isError: true }; + if (!vote) return { content: [{ type: "text", text: "vote is required for vote action" }], isError: true }; - return { - content: [{ type: "text", text: `Error getting pull request changes: ${errorMessage}` }], - isError: true, - }; - } - } - ); + const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); + const userId = userDetails.authenticatedUser.id; - server.tool( - REPO_TOOLS.reply_to_comment, - "Replies to a specific comment on a pull request.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."), - threadId: z.coerce.number().min(1).describe("The ID of the thread to which the comment will be added."), - content: z.string().describe("The content of the comment to be added."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - fullResponse: z.boolean().optional().default(false).describe("Return full comment JSON response instead of a simple confirmation message."), - }, - async ({ repositoryId, pullRequestId, threadId, content, project, fullResponse }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project); + if (!userId) throw new Error("Could not determine authenticated user ID."); - // Check if the comment was successfully created - if (!comment) { - return { - content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], - isError: true, + const voteMap: Record = { + Approved: 10, + ApprovedWithSuggestions: 5, + NoVote: 0, + WaitingForAuthor: -5, + Rejected: -10, }; - } - if (fullResponse) { - return { - content: [{ type: "text", text: JSON.stringify(comment, null, 2) }], + const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => { + if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message)) throw error; + return undefined; + }); + + const reviewerPayload = { + vote: voteMap[vote], + id: userId, + ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}), }; + + await gitApi.createPullRequestReviewer(reviewerPayload as any, repositoryId, pullRequestId, userId, project); + + return { content: [{ type: "text", text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.` }] }; } - return { - content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error replying to comment: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with pull request write operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_pull_request_thread_write ---------------------------------------- server.tool( - REPO_TOOLS.create_pull_request_thread, - "Creates a new comment thread on a pull request.", + REPO_TOOLS.repo_pull_request_thread_write, + "Write operations for pull request comment threads. Use the action parameter to specify the operation.", { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."), - content: z.string().describe("The content of the comment to be added."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - filePath: z.string().optional().describe("The path of the file where the comment thread will be created. (optional)"), + action: z + .enum(["create", "reply", "update_status"]) + .describe( + "The action to perform. Options: create (create a new comment thread on a pull request), reply (reply to a comment in a thread), update_status (update the status of a comment thread)." + ), + repositoryId: z.string().describe("The ID or name of the repository. When using a name instead of a GUID, project must also be provided."), + pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."), + project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name instead of a GUID."), + threadId: z.coerce.number().min(1).optional().describe("The ID of the thread. Required for reply and update_status."), + content: z.string().optional().describe("The content of the comment. Required for create and reply."), status: z .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) .optional() .default(CommentThreadStatus[CommentThreadStatus.Active]) - .describe("The status of the comment thread. Defaults to 'Active'."), - rightFileStartLine: z.coerce - .number() - .min(1) - .optional() - .describe("Position of first character of the thread's span in right file. The line number of a thread's position. Starts at 1. (optional)"), - rightFileStartOffset: z - .number() - .optional() - .describe( - "Start character offset of the thread's span within the line in the right file. The character offset of a thread's position inside of a line. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)" - ), - rightFileEndLine: z - .number() - .optional() - .describe( - "Position of last character of the thread's span in right file. The line number of a thread's position. Starts at 1. Must be set if rightFileStartLine is also specified. (optional)" - ), - rightFileEndOffset: z - .number() - .optional() - .describe( - "Exclusive end character offset of the thread's span within the line in the right file. This value is exclusive: to cover the entire line, set it to (length of the original line text) + 1. When posting a suggestion, always calculate this from the existing file content being replaced, not from the suggestion or replacement text. Must be set if rightFileEndLine is also specified. (optional)" - ), + .describe("The thread status. Used for create (defaults to 'Active') and required for update_status."), + filePath: z.string().optional().describe("The file path for the comment thread. Used for create."), + fullResponse: z.boolean().optional().default(false).describe("Return full JSON response. Used for reply."), + rightFileStartLine: z.coerce.number().min(1).optional().describe("Start line in the right file. Used for create."), + rightFileStartOffset: z.number().optional().describe("Start character offset in the right file. Used for create."), + rightFileEndLine: z.number().optional().describe("End line in the right file. Used for create."), + rightFileEndOffset: z.number().optional().describe("End character offset in the right file. Used for create."), }, - async ({ repositoryId, pullRequestId, content, project, filePath, status, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => { + async ({ action, repositoryId, pullRequestId, project, threadId, content, status, filePath, fullResponse, rightFileStartLine, rightFileStartOffset, rightFileEndLine, rightFileEndOffset }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath; - const threadContext: CommentThreadContext = { filePath: normalizedFilePath }; + if (action === "create") { + if (!content) return { content: [{ type: "text", text: "content is required for create" }], isError: true }; - if (rightFileStartLine !== undefined) { - if (rightFileStartLine < 1) { - return { - content: [{ type: "text", text: "rightFileStartLine must be greater than or equal to 1." }], - isError: true, - }; - } + const normalizedFilePath = filePath && !filePath.startsWith("/") ? `/${filePath}` : filePath; + const threadContext: CommentThreadContext = { filePath: normalizedFilePath }; - threadContext.rightFileStart = { line: rightFileStartLine }; - - if (rightFileStartOffset !== undefined) { - if (rightFileStartOffset < 1) { - return { - content: [{ type: "text", text: "rightFileStartOffset must be greater than or equal to 1." }], - isError: true, - }; + if (rightFileStartLine !== undefined) { + if (rightFileStartLine < 1) return { content: [{ type: "text", text: "rightFileStartLine must be greater than or equal to 1." }], isError: true }; + threadContext.rightFileStart = { line: rightFileStartLine }; + if (rightFileStartOffset !== undefined) { + if (rightFileStartOffset < 1) return { content: [{ type: "text", text: "rightFileStartOffset must be greater than or equal to 1." }], isError: true }; + threadContext.rightFileStart.offset = rightFileStartOffset; } - - threadContext.rightFileStart.offset = rightFileStartOffset; - } - } - - if (rightFileEndLine !== undefined) { - if (rightFileStartLine === undefined) { - return { - content: [{ type: "text", text: "rightFileEndLine must only be specified if rightFileStartLine is also specified." }], - isError: true, - }; } - if (rightFileEndLine < 1) { - return { - content: [{ type: "text", text: "rightFileEndLine must be greater than or equal to 1." }], - isError: true, - }; + if (rightFileEndLine !== undefined) { + if (rightFileStartLine === undefined) return { content: [{ type: "text", text: "rightFileEndLine must only be specified if rightFileStartLine is also specified." }], isError: true }; + if (rightFileEndLine < 1) return { content: [{ type: "text", text: "rightFileEndLine must be greater than or equal to 1." }], isError: true }; + if (rightFileEndOffset === undefined) return { content: [{ type: "text", text: "rightFileEndOffset must be specified if rightFileEndLine is specified." }], isError: true }; + threadContext.rightFileEnd = { line: rightFileEndLine }; + /* istanbul ignore else */ + if (rightFileEndOffset !== undefined) { + if (rightFileEndOffset < 1) return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to 1." }], isError: true }; + threadContext.rightFileEnd.offset = rightFileEndOffset; + } } - if (rightFileEndOffset === undefined) { - return { - content: [{ type: "text", text: "rightFileEndOffset must be specified if rightFileEndLine is specified." }], - isError: true, - }; + if (rightFileEndOffset !== undefined && rightFileEndLine === undefined) { + return { content: [{ type: "text", text: "rightFileEndLine must be specified if rightFileEndOffset is specified." }], isError: true }; } - threadContext.rightFileEnd = { line: rightFileEndLine }; - - if (rightFileEndOffset !== undefined) { - if (rightFileEndOffset < 1) { + if (rightFileStartLine !== undefined && rightFileStartOffset !== undefined) { + if (rightFileEndLine === undefined || rightFileEndOffset === undefined) { return { - content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to 1." }], + content: [{ type: "text", text: "rightFileEndLine and rightFileEndOffset must both be specified when rightFileStartLine and rightFileStartOffset are both specified." }], isError: true, }; } - - threadContext.rightFileEnd.offset = rightFileEndOffset; } - } - - if (rightFileEndOffset !== undefined && rightFileEndLine === undefined) { - return { - content: [{ type: "text", text: "rightFileEndLine must be specified if rightFileEndOffset is specified." }], - isError: true, - }; - } - - if (rightFileStartLine !== undefined && rightFileStartOffset !== undefined) { - if (rightFileEndLine === undefined || rightFileEndOffset === undefined) { - return { - content: [{ type: "text", text: "rightFileEndLine and rightFileEndOffset must both be specified when rightFileStartLine and rightFileStartOffset are both specified." }], - isError: true, - }; - } - } - if (rightFileStartLine !== undefined && rightFileEndLine !== undefined && rightFileStartLine === rightFileEndLine) { - if (rightFileEndOffset !== undefined && rightFileStartOffset !== undefined && rightFileEndOffset < rightFileStartOffset) { - return { - content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to rightFileStartOffset when both are on the same line." }], - isError: true, - }; + if (rightFileStartLine !== undefined && rightFileEndLine !== undefined && rightFileStartLine === rightFileEndLine) { + if (rightFileEndOffset !== undefined && rightFileStartOffset !== undefined && rightFileEndOffset < rightFileStartOffset) { + return { content: [{ type: "text", text: "rightFileEndOffset must be greater than or equal to rightFileStartOffset when both are on the same line." }], isError: true }; + } } - } - const thread = await gitApi.createThread( - { comments: [{ content: content, commentType: 1 }], threadContext: threadContext, status: CommentThreadStatus[status as keyof typeof CommentThreadStatus] }, - repositoryId, - pullRequestId, - project - ); + const thread = await gitApi.createThread( + { comments: [{ content, commentType: 1 }], threadContext, status: CommentThreadStatus[status as keyof typeof CommentThreadStatus] }, + repositoryId, + pullRequestId, + project + ); - const trimmedThread = trimPullRequestThread(thread); + return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + if (action === "reply") { + if (!threadId) return { content: [{ type: "text", text: "threadId is required for reply" }], isError: true }; + if (!content) return { content: [{ type: "text", text: "content is required for reply" }], isError: true }; - return { - content: [{ type: "text", text: `Error creating pull request thread: ${errorMessage}` }], - isError: true, - }; - } - } - ); + const comment = await gitApi.createComment({ content, commentType: 1 }, repositoryId, pullRequestId, threadId, project); - server.tool( - REPO_TOOLS.update_pull_request_thread, - "Updates an existing comment thread on a pull request.", - { - repositoryId: z - .string() - .describe("The ID or name of the repository where the pull request is located. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request where the comment thread exists."), - threadId: z.coerce.number().min(1).describe("The ID of the thread to update."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), - status: z - .enum(getEnumKeys(CommentThreadStatus) as [string, ...string[]]) - .optional() - .describe("The new status for the comment thread."), - }, - async ({ repositoryId, pullRequestId, threadId, project, status }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - const updateRequest: Record = {}; + if (!comment) { + return { content: [{ type: "text", text: `Error: Failed to add comment to thread ${threadId}. The comment was not created successfully.` }], isError: true }; + } - if (status !== undefined) { - updateRequest.status = CommentThreadStatus[status as keyof typeof CommentThreadStatus]; - } + if (fullResponse) return { content: [{ type: "text", text: JSON.stringify(comment, null, 2) }] }; - if (Object.keys(updateRequest).length === 0) { - return { - content: [{ type: "text", text: "Error: At least one field (status) must be provided for update." }], - isError: true, - }; + return { content: [{ type: "text", text: `Comment successfully added to thread ${threadId}.` }] }; } - const thread = await gitApi.updateThread(updateRequest, repositoryId, pullRequestId, threadId, project); + if (action === "update_status") { + if (!threadId) return { content: [{ type: "text", text: "threadId is required for update_status" }], isError: true }; + if (!status) return { content: [{ type: "text", text: "status is required for update_status" }], isError: true }; - if (!thread) { - return { - content: [{ type: "text", text: `Error: Failed to update thread ${threadId}. The thread was not updated successfully.` }], - isError: true, + const updateRequest: Record = { + status: CommentThreadStatus[status as keyof typeof CommentThreadStatus], }; - } - - const trimmedThread = trimPullRequestThread(thread); - - return { - content: [{ type: "text", text: JSON.stringify(trimmedThread, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error updating pull request thread: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - REPO_TOOLS.search_commits, - "Search for commits in a repository with comprehensive filtering capabilities. Supports searching by description/comment text, time range, author and more.", - { - searchText: z.string().describe("Keywords to search for in commit messages"), - project: z - .union([z.string().transform((value) => [value]), z.array(z.string())]) - .optional() - .describe("The names of the projects to search within. If omitted, searches across all projects in the organization."), - repository: z.array(z.string()).optional().describe("The names of the repositories to search within. If omitted, searches across all repositories in the specified projects."), - branch: z.array(z.string()).optional().describe("The names of the repository branches to search within. If omitted, searches across all branches in the specified repositories."), - author: z.array(z.string()).optional().describe("The names of the commit authors to search for. Only full display names are supported."), - commitStartDate: z.string().optional().describe("Filter commits from this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS')"), - commitEndDate: z.string().optional().describe("Filter commits up to this date (format: 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:MM:SS', e.g. '2025-06-19T23:59:59' for full day)"), - orderBy: z.enum(["ASC", "DESC"]).optional().describe("Sort commits by date: 'ASC' for oldest-first, 'DESC' for newest-first. Defaults to relevance if omitted."), - includeFacets: z.boolean().default(false).describe("Include facets in the search results"), - skip: z.coerce.number().default(0).describe("Number of results to skip"), - top: z.coerce.number().default(10).describe("Maximum number of results to return"), - }, - async ({ searchText, project, repository, branch, author, commitStartDate, commitEndDate, orderBy, includeFacets, skip, top }) => { - const accessToken = await tokenProvider(); - const url = `https://almsearch.dev.azure.com/${orgName}/_apis/search/commitSearchResults?api-version=${apiVersion}`; - - const requestBody: Record = { - searchText, - includeFacets, - $skip: skip, - $top: top, - }; - - const filters: Record = {}; - if (project && project.length > 0) filters.projectName = project; - if (repository && repository.length > 0) filters.repositoryName = repository; - if (branch && branch.length > 0) filters.branchName = branch; - if (author && author.length > 0) filters.authorName = author; - if (commitStartDate) filters.commitStartDate = [commitStartDate]; - if (commitEndDate) filters.commitEndDate = [commitEndDate]; - - requestBody.filters = filters; - - if (orderBy) { - requestBody.$orderBy = [{ field: "commitDate", sortOrder: orderBy }]; - } - - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - throw new Error(`Azure DevOps Commit Search API error: ${response.status} ${response.statusText}`); - } - - const result = await response.text(); - return { - content: [{ type: "text", text: result }], - }; - } - ); - - const pullRequestQueryTypesStrings = Object.values(GitPullRequestQueryType).filter((value): value is string => typeof value === "string"); - server.tool( - REPO_TOOLS.list_pull_requests_by_commits, - "Lists pull requests by commit IDs to find which pull requests contain specific commits", - { - project: z.string().describe("Project name or ID"), - repository: z.string().describe("Repository name or ID"), - commits: z.array(z.string()).describe("Array of commit IDs to query for"), - queryType: z - .enum(pullRequestQueryTypesStrings as [string, ...string[]]) - .optional() - .default(GitPullRequestQueryType[GitPullRequestQueryType.LastMergeCommit]) - .describe("Type of query to perform"), - }, - async ({ project, repository, commits, queryType }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + const thread = await gitApi.updateThread(updateRequest, repositoryId, pullRequestId, threadId, project); - const query: GitPullRequestQuery = { - queries: [ - { - items: commits, - type: GitPullRequestQueryType[queryType as keyof typeof GitPullRequestQueryType], - } as GitPullRequestQueryInput, - ], - }; + if (!thread) { + return { content: [{ type: "text", text: `Error: Failed to update thread ${threadId}. The thread was not updated successfully.` }], isError: true }; + } - const queryResult = await gitApi.getPullRequestQuery(query, repository, project); + return { content: [{ type: "text", text: JSON.stringify(trimPullRequestThread(thread), null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error querying pull requests by commits: ${errorMessage}` }], - isError: true, - }; + return { content: [{ type: "text", text: `Error with pull request thread write operation: ${errorMessage}` }], isError: true }; } } ); + // --- repo_create_branch ---------------------------------------------------- server.tool( - REPO_TOOLS.vote_pull_request, - "Cast a vote on a pull request. Automatically adds the current user as a reviewer if they are not already one.", + REPO_TOOLS.repo_create_branch, + "Create a new branch in the repository.", { - repositoryId: z.string().describe("The ID or name of the repository. When using a repository name instead of a GUID, the project parameter must also be provided."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request."), - vote: z.enum(["Approved", "ApprovedWithSuggestions", "NoVote", "WaitingForAuthor", "Rejected"]).describe("The vote to cast: Approved(10), Suggestions(5), None(0), Waiting(-5), Rejected(-10)."), + repositoryId: z + .string() + .describe("The ID or name of the repository where the branch will be created. When using a repository name instead of a GUID, the project parameter must also be provided."), + branchName: z.string().describe("The name of the new branch to create, e.g., 'feature-branch'."), + sourceBranchName: z.string().optional().default("main").describe("The name of the source branch to create the new branch from. Defaults to 'main'."), + sourceCommitId: z.string().optional().describe("The commit ID to create the branch from. If not provided, uses the latest commit of the source branch."), project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a repository name instead of a GUID."), }, - async ({ repositoryId, pullRequestId, vote, project }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - const userDetails = await getCurrentUserDetails(tokenProvider, connectionProvider, userAgentProvider); - const userId = userDetails.authenticatedUser.id; - - if (!userId) { - throw new Error("Could not determine authenticated user ID."); - } - - const voteMap: Record = { - Approved: 10, - ApprovedWithSuggestions: 5, - NoVote: 0, - WaitingForAuthor: -5, - Rejected: -10, - }; - - const existingReviewer = await gitApi.getPullRequestReviewer(repositoryId, pullRequestId, userId, project).catch((error) => { - if (!(error instanceof Error) || !/not found|reviewer does not exist/i.test(error.message)) { - throw error; - } - - return undefined; - }); - - const reviewerPayload = { - vote: voteMap[vote], - id: userId, - ...(existingReviewer?.isRequired !== undefined ? { isRequired: existingReviewer.isRequired } : {}), - }; - - await gitApi.createPullRequestReviewer(reviewerPayload as any, repositoryId, pullRequestId, userId, project); - - return { - content: [ - { - type: "text", - text: `Successfully cast vote '${vote}' on PR #${pullRequestId}.`, - }, - ], - }; - } - ); - - server.tool( - REPO_TOOLS.list_directory, - "List files and folders in a directory within a repository. Useful for exploring the structure of a codebase or finding related files. Returns isError: true if the path is not found.", - { - repositoryId: z.string().describe("The ID or name of the repository."), - path: z.string().optional().default("/").describe("The directory path to list (e.g., '/src' or '/src/components'). Defaults to repository root."), - project: z.string().optional().describe("Project ID or name. Required if repositoryId is a name rather than a GUID."), - version: z.string().optional().describe("The version identifier - branch name (e.g., 'main'), tag name, or commit SHA. Defaults to the repository's default branch."), - versionType: z.enum(["Branch", "Commit", "Tag"]).optional().default("Branch").describe("The type of version identifier: 'Branch', 'Commit', or 'Tag'. Defaults to 'Branch'."), - recursive: z.boolean().optional().default(false).describe("Whether to list items recursively. Defaults to false."), - recursionDepth: z.coerce.number().min(1).optional().default(1).describe("Maximum depth for recursive listing (1-10). Only applies when recursive is true. Defaults to 1."), - }, - async ({ repositoryId, path, project, version, versionType, recursive, recursionDepth }) => { + async ({ repositoryId, branchName, sourceBranchName, sourceCommitId, project }) => { try { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); - const versionDescriptor = buildVersionDescriptor(version, versionType); - const clampedDepth = Math.min(Math.max(recursionDepth || 1, 1), 10); - - let recursionType = VersionControlRecursionType.OneLevel; - - if (recursive) { - recursionType = VersionControlRecursionType.Full; - } - - const items = await gitApi.getItems(repositoryId, project, path, recursionType, true, false, false, false, versionDescriptor); - - if (!items || items.length === 0) { - return { - content: [{ type: "text", text: `No items found at path: ${path}. The path may not exist in the repository.` }], - isError: true, - }; - } - - let filteredItems = items; - - if (recursive && clampedDepth < 10) { - const basePath = path === "/" ? "" : path; - const baseDepth = basePath.split("/").filter((p) => p).length; + let commitId = sourceCommitId; - filteredItems = items.filter((item) => { - if (!item.path) return false; - const itemDepth = item.path.split("/").filter((p) => p).length; - return itemDepth <= baseDepth + clampedDepth; - }); + if (!commitId) { + const sourceRefName = `refs/heads/${sourceBranchName}`; + try { + const sourceBranch = await gitApi.getRefs(repositoryId, project, "heads/", false, false, undefined, false, undefined, sourceBranchName); + const branch = sourceBranch.find((b) => b.name === sourceRefName); + if (!branch || !branch.objectId) { + return { content: [{ type: "text", text: `Error: Source branch '${sourceBranchName}' not found in repository ${repositoryId}` }], isError: true }; + } + commitId = branch.objectId; + } catch (error) { + return { content: [{ type: "text", text: `Error retrieving source branch '${sourceBranchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true }; + } } - const formattedItems = filteredItems.map((item) => ({ - path: item.path, - isFolder: item.isFolder, - gitObjectType: item.gitObjectType, - commitId: item.commitId, - contentMetadata: item.contentMetadata - ? { - contentType: item.contentMetadata.contentType, - fileName: item.contentMetadata.fileName, - } - : undefined, - })); - - const response = { - count: formattedItems.length, - path: path, - recursive: recursive, - recursionDepth: recursive ? clampedDepth : undefined, - items: formattedItems, - }; - - return { - content: [{ type: "text", text: JSON.stringify(response, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error listing directory: ${errorMessage}` }], - isError: true, + const refUpdate = { + name: `refs/heads/${branchName}`, + newObjectId: commitId, + oldObjectId: "0000000000000000000000000000000000000000", }; - } - } - ); - - // ── Get file content at a specific version (branch, tag, or commit) ── - const fileVersionTypeStrings = getEnumKeys(GitVersionType); - server.tool( - REPO_TOOLS.get_file_content, - "Get the content of a file from a Git repository at a specific version (branch, tag, or commit SHA). " + - "Useful for reading source files from PR branches, specific commits, or tags without having them checked out locally. " + - "Returns isError: true if the file is not found.", - { - repositoryId: z.string().describe("The ID (GUID) or name of the repository."), - path: z.string().describe("The full path to the file in the repository, e.g., '/src/main.ts' or 'src/main.ts'."), - project: z.string().optional().describe("Project ID or project name. Required when repositoryId is a name."), - version: z - .string() - .optional() - .describe("Version string: branch name (e.g. 'main'), tag name, or commit SHA. " + "Defaults to the repository's default branch if not specified."), - versionType: z - .enum(fileVersionTypeStrings as [string, ...string[]]) - .optional() - .default("Commit") - .describe("How to interpret the 'version' parameter. Defaults to 'Commit'."), - }, - async ({ repositoryId, path, project, version, versionType }) => { - try { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); + try { + const result = await gitApi.updateRefs([refUpdate], repositoryId, project); - // Build the version descriptor if a version was specified - const versionDescriptor: GitVersionDescriptor | undefined = version - ? { - version: version, - versionType: GitVersionType[versionType as keyof typeof GitVersionType], - } - : undefined; - - // getItemText returns a ReadableStream of the file content as text - const stream = await gitApi.getItemText( - repositoryId, - path, - project, - undefined, // scopePath - undefined, // recursionLevel - undefined, // includeContentMetadata - undefined, // latestProcessedChange - false, // download - versionDescriptor, - true // includeContent - ); - - const content = await streamToString(stream); - - const streamError = extractAdoStreamError(content); - if (streamError) { - return { - content: [{ type: "text", text: `Error getting file content for '${path}': ${streamError}` }], - isError: true, - }; + if (result && result.length > 0 && result[0].success) { + return { content: [{ type: "text", text: `Branch '${branchName}' created successfully from '${sourceBranchName}' (${commitId})` }] }; + } else { + const errorMessage = result && result.length > 0 && result[0].customMessage ? result[0].customMessage : "Unknown error occurred during branch creation"; + return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${errorMessage}` }], isError: true }; + } + } catch (error) { + return { content: [{ type: "text", text: `Error creating branch '${branchName}': ${error instanceof Error ? error.message : String(error)}` }], isError: true }; } - - return { - content: [{ type: "text", text: content }], - }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [ - { - type: "text", - text: `Error getting file content for '${path}': ${errorMessage}`, - }, - ], - isError: true, - }; + return { content: [{ type: "text", text: `Error creating branch: ${errorMessage}` }], isError: true }; } } ); diff --git a/src/tools/test-plans.ts b/src/tools/test-plans.ts index 7f7b33b5..b8e915d7 100644 --- a/src/tools/test-plans.ts +++ b/src/tools/test-plans.ts @@ -7,575 +7,436 @@ import { TestPlanCreateParams } from "azure-devops-node-api/interfaces/TestPlanI import { z } from "zod"; import { apiVersion } from "../utils.js"; -const Test_Plan_Tools = { - create_test_plan: "testplan_create_test_plan", - create_test_case: "testplan_create_test_case", - update_test_case_steps: "testplan_update_test_case_steps", - add_test_cases_to_suite: "testplan_add_test_cases_to_suite", +const TEST_PLAN_TOOLS = { + testplan: "testplan", test_results_from_build_id: "testplan_show_test_results_from_build_id", - list_test_cases: "testplan_list_test_cases", - list_test_plans: "testplan_list_test_plans", - list_test_suites: "testplan_list_test_suites", - create_test_suite: "testplan_create_test_suite", + testplan_test_plan_write: "testplan_test_plan_write", + testplan_test_suite_write: "testplan_test_suite_write", + testplan_test_case_write: "testplan_test_case_write", }; function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider?: () => string) { + // ─── testplan (read-only) ──────────────────────────────────────────── server.tool( - Test_Plan_Tools.list_test_plans, - "Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information.", + TEST_PLAN_TOOLS.testplan, + "Retrieve paginated test plan, suite, and case data for a project. Use the action parameter to specify the operation. When a response includes a continuationToken, pass it back with the same action and query parameters to fetch the next batch; null token indicates the last batch.", { + action: z + .enum(["list_plans", "list_suites", "list_cases"]) + .describe("The action to perform. Options: list_plans (list test plans in a project), list_suites (list test suites under a test plan), list_cases (list test cases under a test suite)."), project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Defaults to true."), - includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan."), - continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."), + filterActivePlans: z.boolean().default(true).describe("Filter to include only active test plans. Used for: list_plans. Defaults to true."), + includePlanDetails: z.boolean().default(false).describe("Include detailed information about each test plan. Used for: list_plans."), + planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: list_suites, list_cases."), + suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: list_cases."), + continuationToken: z.string().optional().describe("Token to continue fetching results from a previous request. Used for: list_plans, list_suites, list_cases."), }, - async ({ project, filterActivePlans, includePlanDetails, continuationToken }) => { + async ({ action, project, filterActivePlans, includePlanDetails, planId, suiteId, continuationToken }) => { try { const connection = await connectionProvider(); const accessToken = await tokenProvider(); - const params = new URLSearchParams({ "api-version": apiVersion }); - if (filterActivePlans) params.append("filterActivePlans", "true"); - if (includePlanDetails) params.append("includePlanDetails", "true"); - if (continuationToken) params.append("continuationToken", continuationToken); - const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`; + const headers: Record = { Authorization: `Bearer ${accessToken}`, }; const userAgent = userAgentProvider?.(); + if (userAgent) { headers["User-Agent"] = userAgent; } - const response = await fetch(url, { - method: "GET", - headers, - }); + if (action === "list_plans") { + const params = new URLSearchParams({ "api-version": apiVersion }); + if (filterActivePlans) params.append("filterActivePlans", "true"); + if (includePlanDetails) params.append("includePlanDetails", "true"); + if (continuationToken) params.append("continuationToken", continuationToken); + const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans?${params.toString()}`; - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to list test plans (${response.status}): ${errorText}`); - } + const response = await fetch(url, { method: "GET", headers }); - const body = await response.json(); - const testPlans = body.value ?? []; - const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to list test plans (${response.status}): ${errorText}`); + } - const result: { testPlans: typeof testPlans; continuationToken?: string } = { - testPlans: testPlans, - }; - if (nextToken) { - result.continuationToken = nextToken; - } + const body = await response.json(); + const testPlans = body.value ?? []; + const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error listing test plans: ${errorMessage}` }], - isError: true, - }; - } - } - ); + const result: { testPlans: typeof testPlans; continuationToken?: string } = { testPlans }; + if (nextToken) result.continuationToken = nextToken; - server.tool( - Test_Plan_Tools.create_test_plan, - "Creates a new test plan in the project.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project where the test plan will be created."), - name: z.string().describe("The name of the test plan to be created."), - iteration: z.string().describe("The iteration path for the test plan"), - description: z.string().optional().describe("The description of the test plan"), - startDate: z.string().optional().describe("The start date of the test plan"), - endDate: z.string().optional().describe("The end date of the test plan"), - areaPath: z.string().optional().describe("The area path for the test plan"), - }, - async ({ project, name, iteration, description, startDate, endDate, areaPath }) => { - try { - const connection = await connectionProvider(); - const testPlanApi = await connection.getTestPlanApi(); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } else if (action === "list_suites") { + if (!planId) return { content: [{ type: "text", text: "planId is required for list_suites" }], isError: true }; - const testPlanToCreate: TestPlanCreateParams = { - name, - iteration, - description, - startDate: startDate ? new Date(startDate) : undefined, - endDate: endDate ? new Date(endDate) : undefined, - areaPath, - }; + const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" }); + if (continuationToken) params.append("continuationToken", continuationToken); + const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`; - const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project); + const response = await fetch(url, { method: "GET", headers }); - return { - content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - Test_Plan_Tools.create_test_suite, - "Creates a new test suite in a test plan.", - { - project: z.string().describe("Project ID or project name"), - planId: z.coerce.number().min(1).describe("ID of the test plan that contains the suites"), - parentSuiteId: z.coerce.number().min(1).describe("ID of the parent suite under which the new suite will be created, if not given by user this can be id of a root suite of the test plan"), - name: z.string().describe("Name of the child test suite"), - }, - async ({ project, planId, parentSuiteId, name }) => { - const maxRetries = 5; - const baseDelay = 500; // milliseconds + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to list test suites (${response.status}): ${errorText}`); + } - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - const connection = await connectionProvider(); - const testPlanApi = await connection.getTestPlanApi(); - - const testSuiteToCreate = { - name, - parentSuite: { - id: parentSuiteId, - name: "", - }, - suiteType: 2, - }; + const body = await response.json(); + const testSuites = body.value ?? []; + const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; + + const suiteMap = new Map(); + testSuites.forEach((suite: any) => { + suiteMap.set(suite.id, { + id: suite.id, + name: suite.name, + parentSuiteId: suite.parentSuite?.id, + children: [] as any[], + }); + }); - const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId); + const roots: any[] = []; + suiteMap.forEach((suite: any) => { + if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) { + suiteMap.get(suite.parentSuiteId).children.push(suite); + } else { + roots.push(suite); + } + }); - return { - content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }], + const cleanSuite = (suite: any): any => { + const cleaned: any = { id: suite.id, name: suite.name }; + if (suite.children && suite.children.length > 0) { + cleaned.children = suite.children.map((child: any) => cleanSuite(child)); + } + return cleaned; }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - // Check if it's a concurrency conflict error - const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else"); + const cleanedSuites = roots.map((root: any) => cleanSuite(root)); + const result: { testSuites: typeof cleanedSuites; continuationToken?: string } = { testSuites: cleanedSuites }; + if (nextToken) result.continuationToken = nextToken; - // If it's a concurrency error and we have retries left, wait and retry - if (isConcurrencyError && attempt < maxRetries) { - const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; // Exponential backoff with jitter - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; // Retry - } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } else if (action === "list_cases") { + if (!planId) return { content: [{ type: "text", text: "planId is required for list_cases" }], isError: true }; + if (!suiteId) return { content: [{ type: "text", text: "suiteId is required for list_cases" }], isError: true }; - // If not a concurrency error or out of retries, return error - return { - content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }], - isError: true, - }; - } - } + const params = new URLSearchParams({ "api-version": "7.2-preview.3" }); + if (continuationToken) params.append("continuationToken", continuationToken); + const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites/${suiteId}/TestCase?${params.toString()}`; - // This should never be reached, but TypeScript requires a return value - return { - content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }], - isError: true, - }; - } - ); + const response = await fetch(url, { method: "GET", headers }); - server.tool( - Test_Plan_Tools.add_test_cases_to_suite, - "Adds existing test cases to a test suite.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - planId: z.coerce.number().min(1).describe("The ID of the test plan."), - suiteId: z.coerce.number().min(1).describe("The ID of the test suite."), - testCaseIds: z.string().or(z.array(z.string())).describe("The ID(s) of the test case(s) to add. "), - }, - async ({ project, planId, suiteId, testCaseIds }) => { - try { - const connection = await connectionProvider(); - const testApi = await connection.getTestApi(); + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to list test cases (${response.status}): ${errorText}`); + } - // If testCaseIds is an array, convert it to comma-separated string - const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds; + const body = await response.json(); + const testcases = body.value ?? []; + const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; - const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString); + const result: { testCases: typeof testcases; continuationToken?: string } = { testCases: testcases }; + if (nextToken) result.continuationToken = nextToken; - return { - content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }], - }; + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const prefix = action === "list_plans" ? "Error listing test plans" : action === "list_suites" ? "Error listing test suites" : "Error listing test cases"; return { - content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }], + content: [{ type: "text", text: `${prefix}: ${errorMessage}` }], isError: true, }; } } ); + // ─── testplan_show_test_results_from_build_id ────────────────────────────────────── server.tool( - Test_Plan_Tools.create_test_case, - "Creates a new test case work item.", + TEST_PLAN_TOOLS.test_results_from_build_id, + "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", { project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - title: z.string().describe("The title of the test case."), - steps: z - .string() - .optional() - .describe( - "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." - ), - priority: z.coerce.number().optional().describe("The priority of the test case."), - areaPath: z.string().optional().describe("The area path for the test case."), - iterationPath: z.string().optional().describe("The iteration path for the test case."), - testsWorkItemId: z.coerce.number().min(1).optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."), + buildid: z.coerce.number().min(1).describe("The ID of the build."), + outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."), }, - async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => { + async ({ project, buildid, outcomes }) => { try { const connection = await connectionProvider(); - const witClient = await connection.getWorkItemTrackingApi(); - - let stepsXml; - if (steps) { - stepsXml = convertStepsToXml(steps); - } - - // Create JSON patch document for work item - const patchDocument = []; - - patchDocument.push({ - op: "add", - path: "/fields/System.Title", - value: title, - }); - - if (testsWorkItemId) { - patchDocument.push({ - op: "add", - path: "/relations/-", - value: { - rel: "Microsoft.VSTS.Common.TestedBy-Reverse", - url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`, - }, - }); - } - - if (stepsXml) { - patchDocument.push({ - op: "add", - path: "/fields/Microsoft.VSTS.TCM.Steps", - value: stepsXml, - }); - } + const testResultsApi = await connection.getTestResultsApi(); - if (priority) { - patchDocument.push({ - op: "add", - path: "/fields/Microsoft.VSTS.Common.Priority", - value: priority, - }); - } + const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined; - if (areaPath) { - patchDocument.push({ - op: "add", - path: "/fields/System.AreaPath", - value: areaPath, - }); - } + const testResultDetails = await testResultsApi.getTestResultDetailsForBuild(project, buildid, undefined, undefined, outcomeFilter, undefined, true); - if (iterationPath) { - patchDocument.push({ - op: "add", - path: "/fields/System.IterationPath", - value: iterationPath, - }); + const allResults: any[] = []; + if (testResultDetails.resultsForGroup) { + for (const group of testResultDetails.resultsForGroup) { + if (group.results) { + for (const result of group.results) { + allResults.push(result); + } + } + } } - const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case"); + const formattedResults = allResults.map((r) => ({ + id: r.id, + testCaseTitle: r.testCaseTitle, + outcome: r.outcome, + errorMessage: r.errorMessage, + stackTrace: r.stackTrace, + automatedTestName: r.automatedTestName, + automatedTestStorage: r.automatedTestStorage, + durationInMs: r.durationInMs, + runId: r.testRun?.id, + })); return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return { - content: [{ type: "text", text: `Error creating test case: ${errorMessage}` }], + content: [{ type: "text", text: `Error fetching test results: ${errorMessage}` }], isError: true, }; } } ); + // ─── testplan_test_plan_write ───────────────────────────────────────────────────── server.tool( - Test_Plan_Tools.update_test_case_steps, - "Update an existing test case work item.", + TEST_PLAN_TOOLS.testplan_test_plan_write, + "Write operations for test plans. Use the action parameter to specify the operation.", { - id: z.coerce.number().min(1).describe("The ID of the test case work item to update."), - steps: z - .string() - .describe( - "The steps to reproduce the test case. Make sure to format each step as '1. Step one|Expected result one\n2. Step two|Expected result two. USE '|' as the delimiter between step and expected result. DO NOT use '|' in the description of the step or expected result." - ), + action: z.enum(["create"]).describe("The action to perform. Options: create (create a new test plan)."), + project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), + name: z.string().optional().describe("The name of the test plan. Required for: create."), + iteration: z.string().optional().describe("The iteration path for the test plan. Required for: create."), + description: z.string().optional().describe("The description of the test plan. Used for: create."), + startDate: z.string().optional().describe("The start date of the test plan. Used for: create."), + endDate: z.string().optional().describe("The end date of the test plan. Used for: create."), + areaPath: z.string().optional().describe("The area path for the test plan. Used for: create."), }, - async ({ id, steps }) => { + async ({ project, name, iteration, description, startDate, endDate, areaPath }) => { try { - const connection = await connectionProvider(); - const witClient = await connection.getWorkItemTrackingApi(); - - let stepsXml; - if (steps) { - stepsXml = convertStepsToXml(steps); - } + if (!name) return { content: [{ type: "text", text: "name is required for create" }], isError: true }; + if (!iteration) return { content: [{ type: "text", text: "iteration is required for create" }], isError: true }; - // Create JSON patch document for work item - const patchDocument = []; + const connection = await connectionProvider(); + const testPlanApi = await connection.getTestPlanApi(); - if (stepsXml) { - patchDocument.push({ - op: "add", - path: "/fields/Microsoft.VSTS.TCM.Steps", - value: stepsXml, - }); - } + const testPlanToCreate: TestPlanCreateParams = { + name, + iteration, + description, + startDate: startDate ? new Date(startDate) : undefined, + endDate: endDate ? new Date(endDate) : undefined, + areaPath, + }; - const workItem = await witClient.updateWorkItem({}, patchDocument, id); + const createdTestPlan = await testPlanApi.createTestPlan(testPlanToCreate, project); return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + content: [{ type: "text", text: JSON.stringify(createdTestPlan, null, 2) }], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return { - content: [{ type: "text", text: `Error updating test case steps: ${errorMessage}` }], + content: [{ type: "text", text: `Error creating test plan: ${errorMessage}` }], isError: true, }; } } ); + // ─── testplan_test_suite_write ──────────────────────────────────────────────────── server.tool( - Test_Plan_Tools.list_test_cases, - "Gets a list of test cases in the test plan.", + TEST_PLAN_TOOLS.testplan_test_suite_write, + "Write operations for test suites. Use the action parameter to specify the operation.", { + action: z + .enum(["create", "add_test_cases"]) + .describe("The action to perform. Options: create (create a new test suite in a test plan), add_test_cases (add existing test cases to a test suite)."), project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - planid: z.coerce.number().min(1).describe("The ID of the test plan."), - suiteid: z.coerce.number().min(1).describe("The ID of the test suite."), - continuationToken: z.string().optional().describe("Token to continue fetching test cases from a previous request."), + planId: z.coerce.number().min(1).optional().describe("The ID of the test plan. Required for: create, add_test_cases."), + parentSuiteId: z.coerce.number().min(1).optional().describe("ID of the parent suite under which the new suite will be created. Required for: create."), + name: z.string().optional().describe("Name of the child test suite. Required for: create."), + suiteId: z.coerce.number().min(1).optional().describe("The ID of the test suite. Required for: add_test_cases."), + testCaseIds: z.string().or(z.array(z.string())).optional().describe("The ID(s) of the test case(s) to add. Required for: add_test_cases."), }, - async ({ project, planid, suiteid, continuationToken }) => { + async ({ action, project, planId, parentSuiteId, name, suiteId, testCaseIds }) => { try { - const connection = await connectionProvider(); - const accessToken = await tokenProvider(); - const params = new URLSearchParams({ "api-version": "7.2-preview.3" }); - if (continuationToken) params.append("continuationToken", continuationToken); - const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planid}/Suites/${suiteid}/TestCase?${params.toString()}`; - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - }; + if (action === "create") { + if (!planId) return { content: [{ type: "text", text: "planId is required for create" }], isError: true }; + if (!parentSuiteId) return { content: [{ type: "text", text: "parentSuiteId is required for create" }], isError: true }; + if (!name) return { content: [{ type: "text", text: "name is required for create" }], isError: true }; + + const maxRetries = 5; + const baseDelay = 500; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const connection = await connectionProvider(); + const testPlanApi = await connection.getTestPlanApi(); + + const testSuiteToCreate = { + name, + parentSuite: { id: parentSuiteId, name: "" }, + suiteType: 2, + }; + + const createdTestSuite = await testPlanApi.createTestSuite(testSuiteToCreate, project, planId); + + return { + content: [{ type: "text", text: JSON.stringify(createdTestSuite, null, 2) }], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const isConcurrencyError = errorMessage.includes("TF26071") || errorMessage.includes("got update") || errorMessage.includes("changed by someone else"); + + if (isConcurrencyError && attempt < maxRetries) { + const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 200; + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } - const userAgent = userAgentProvider?.(); - if (userAgent) { - headers["User-Agent"] = userAgent; - } + return { + content: [{ type: "text", text: `Error creating test suite: ${errorMessage}` }], + isError: true, + }; + } + } - const response = await fetch(url, { - method: "GET", - headers, - }); + /* istanbul ignore next */ + return { + content: [{ type: "text", text: "Error creating test suite: Maximum retries exceeded" }], + isError: true, + }; + } else if (action === "add_test_cases") { + if (!planId) return { content: [{ type: "text", text: "planId is required for add_test_cases" }], isError: true }; + if (!suiteId) return { content: [{ type: "text", text: "suiteId is required for add_test_cases" }], isError: true }; + if (!testCaseIds) return { content: [{ type: "text", text: "testCaseIds is required for add_test_cases" }], isError: true }; - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to list test cases (${response.status}): ${errorText}`); - } + const connection = await connectionProvider(); + const testApi = await connection.getTestApi(); - const body = await response.json(); - const testcases = body.value ?? []; - const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; + const testCaseIdsString = Array.isArray(testCaseIds) ? testCaseIds.join(",") : testCaseIds; + const addedTestCases = await testApi.addTestCasesToSuite(project, planId, suiteId, testCaseIdsString); - const result: { testCases: typeof testcases; continuationToken?: string } = { - testCases: testcases, - }; - if (nextToken) { - result.continuationToken = nextToken; + return { + content: [{ type: "text", text: JSON.stringify(addedTestCases, null, 2) }], + }; } - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return { - content: [{ type: "text", text: `Error listing test cases: ${errorMessage}` }], + content: [{ type: "text", text: `Error adding test cases to suite: ${errorMessage}` }], isError: true, }; } } ); + // ─── testplan_test_case_write ───────────────────────────────────────────────────── server.tool( - Test_Plan_Tools.test_results_from_build_id, - "Gets a list of test results for a given project and build ID. Can filter by test outcome (e.g. Failed, Passed, Aborted). Returns test case titles, error messages, stack traces, and outcomes. Efficiently handles builds with large numbers of test runs.", + TEST_PLAN_TOOLS.testplan_test_case_write, + "Write operations for test cases. Use the action parameter to specify the operation.", { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - buildid: z.coerce.number().min(1).describe("The ID of the build."), - outcomes: z.array(z.string()).optional().describe("Filter results by test outcome, e.g. ['Failed', 'Passed', 'Aborted']."), + action: z.enum(["create", "update_steps"]).describe("The action to perform. Options: create (create a new test case work item), update_steps (update steps on an existing test case)."), + project: z.string().optional().describe("The unique identifier (ID or name) of the Azure DevOps project. Required for: create."), + title: z.string().optional().describe("The title of the test case. Required for: create."), + priority: z.coerce.number().optional().describe("The priority of the test case. Used for: create."), + areaPath: z.string().optional().describe("The area path for the test case. Used for: create."), + iterationPath: z.string().optional().describe("The iteration path for the test case. Used for: create."), + testsWorkItemId: z.coerce.number().min(1).optional().describe("Work item ID to set as a Microsoft.VSTS.Common.TestedBy-Reverse link. Used for: create."), + id: z.coerce.number().min(1).optional().describe("The ID of the test case work item to update. Required for: update_steps."), + steps: z + .string() + .optional() + .describe( + "The steps for the test case. Format each step as '1. Step one|Expected result one\n2. Step two|Expected result two'. Use '|' as the delimiter between step and expected result. Required for: update_steps. Used for: create." + ), }, - async ({ project, buildid, outcomes }) => { + async ({ action, project, title, steps, priority, areaPath, iterationPath, testsWorkItemId, id }) => { try { - const connection = await connectionProvider(); - const testResultsApi = await connection.getTestResultsApi(); + if (action === "create") { + if (!project) return { content: [{ type: "text", text: "project is required for create" }], isError: true }; + if (!title) return { content: [{ type: "text", text: "title is required for create" }], isError: true }; - // Build filter expression for outcomes if specified. - // The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated) - const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined; + const connection = await connectionProvider(); + const witClient = await connection.getWorkItemTrackingApi(); - // Fetch test result details for the build in a single API call - // This is more efficient than getTestRuns + getTestResults per run, - // especially for builds with many test runs (e.g., cloud testing with one run per test case) - const testResultDetails = await testResultsApi.getTestResultDetailsForBuild( - project, - buildid, - undefined, // publishContext - undefined, // groupBy - outcomeFilter, // filter by outcome - undefined, // orderby - true // shouldIncludeResults - get individual test results, not just aggregates - ); - - // Extract individual test results from the grouped response - const allResults: any[] = []; - if (testResultDetails.resultsForGroup) { - for (const group of testResultDetails.resultsForGroup) { - if (group.results) { - for (const result of group.results) { - allResults.push(result); - } - } + let stepsXml; + if (steps) { + stepsXml = convertStepsToXml(steps); } - } - // Format results to extract useful fields - const formattedResults = allResults.map((r) => ({ - id: r.id, - testCaseTitle: r.testCaseTitle, - outcome: r.outcome, - errorMessage: r.errorMessage, - stackTrace: r.stackTrace, - automatedTestName: r.automatedTestName, - automatedTestStorage: r.automatedTestStorage, - durationInMs: r.durationInMs, - runId: r.testRun?.id, - })); + const patchDocument: any[] = []; - return { - content: [{ type: "text", text: JSON.stringify(formattedResults, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error fetching test results: ${errorMessage}` }], - isError: true, - }; - } - } - ); + patchDocument.push({ op: "add", path: "/fields/System.Title", value: title }); - server.tool( - Test_Plan_Tools.list_test_suites, - "Retrieve a paginated list of test suites from an Azure DevOps project and Test Plan Id.", - { - project: z.string().describe("The unique identifier (ID or name) of the Azure DevOps project."), - planId: z.coerce.number().min(1).describe("The ID of the test plan."), - continuationToken: z.string().optional().describe("Token to continue fetching test plans from a previous request."), - }, - async ({ project, planId, continuationToken }) => { - try { - const connection = await connectionProvider(); - const accessToken = await tokenProvider(); - const params = new URLSearchParams({ "api-version": apiVersion, "expand": "children" }); - if (continuationToken) params.append("continuationToken", continuationToken); - const url = `${connection.serverUrl}/${encodeURIComponent(project)}/_apis/testplan/Plans/${planId}/Suites?${params.toString()}`; - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - }; + if (testsWorkItemId) { + patchDocument.push({ + op: "add", + path: "/relations/-", + value: { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: `${connection.serverUrl}/${project}/_apis/wit/workItems/${testsWorkItemId}`, + }, + }); + } - const userAgent = userAgentProvider?.(); - if (userAgent) { - headers["User-Agent"] = userAgent; - } + if (stepsXml) { + patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml }); + } - const response = await fetch(url, { - method: "GET", - headers, - }); + if (priority) { + patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.Common.Priority", value: priority }); + } - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to list test suites (${response.status}): ${errorText}`); - } + if (areaPath) { + patchDocument.push({ op: "add", path: "/fields/System.AreaPath", value: areaPath }); + } - const body = await response.json(); - const testSuites = body.value ?? []; - const nextToken = response.headers.get("x-ms-continuationtoken") ?? undefined; - - // The API returns a flat list where the root suite is first, followed by all nested suites - // We need to build a proper hierarchy by creating a map and assembling the tree - - // Create a map of all suites by ID for quick lookup - const suiteMap = new Map(); - testSuites.forEach((suite: any) => { - suiteMap.set(suite.id, { - id: suite.id, - name: suite.name, - parentSuiteId: suite.parentSuite?.id, - children: [] as any[], - }); - }); - - // Build the hierarchy by linking children to parents - const roots: any[] = []; - suiteMap.forEach((suite: any) => { - if (suite.parentSuiteId && suiteMap.has(suite.parentSuiteId)) { - // This is a child suite, add it to its parent's children array - const parent = suiteMap.get(suite.parentSuiteId); - parent.children.push(suite); - } else { - // This is a root suite (no parent or parent not in map) - roots.push(suite); + if (iterationPath) { + patchDocument.push({ op: "add", path: "/fields/System.IterationPath", value: iterationPath }); } - }); - // Clean up the output - remove parentSuiteId and empty children arrays - const cleanSuite = (suite: any): any => { - const cleaned: any = { - id: suite.id, - name: suite.name, + const workItem = await witClient.createWorkItem({}, patchDocument, project, "Test Case"); + + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], }; - if (suite.children && suite.children.length > 0) { - cleaned.children = suite.children.map((child: any) => cleanSuite(child)); - } - return cleaned; - }; + } else if (action === "update_steps") { + if (!id) return { content: [{ type: "text", text: "id is required for update_steps" }], isError: true }; + if (!steps) return { content: [{ type: "text", text: "steps is required for update_steps" }], isError: true }; - const cleanedSuites = roots.map((root: any) => cleanSuite(root)); + const connection = await connectionProvider(); + const witClient = await connection.getWorkItemTrackingApi(); - const result: { testSuites: typeof cleanedSuites; continuationToken?: string } = { - testSuites: cleanedSuites, - }; - if (nextToken) { - result.continuationToken = nextToken; - } + const stepsXml = convertStepsToXml(steps); + const patchDocument: any[] = []; - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; + patchDocument.push({ op: "add", path: "/fields/Microsoft.VSTS.TCM.Steps", value: stepsXml }); + + const workItem = await witClient.updateWorkItem({}, patchDocument, id); + + return { + content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], + }; + } + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const prefix = action === "create" ? "Error creating test case" : "Error updating test case steps"; return { - content: [{ type: "text", text: `Error listing test suites: ${errorMessage}` }], + content: [{ type: "text", text: `${prefix}: ${errorMessage}` }], isError: true, }; } @@ -589,7 +450,6 @@ function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise< * element, which is the format Azure DevOps expects for rendered step content. */ function formatStepContent(text: string): string { - // Convert Markdown markers to HTML tags (** before * and __ before _ to avoid conflicts) const htmlContent = text .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/\*(.+?)\*/g, "$1") @@ -597,7 +457,6 @@ function formatStepContent(text: string): string { .replace(/`(.+?)`/g, "$1") .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1'); - // Wrap in ADO rich text envelope and XML-escape the entire HTML string return escapeXml(`${htmlContent}`); } @@ -605,26 +464,22 @@ function formatStepContent(text: string): string { * Helper function to convert steps text to XML format required */ function convertStepsToXml(steps: string): string { - // Accepts steps in the format: '1. Step one|Expected result one\n2. Step two|Expected result two' const stepsLines = steps.split("\n").filter((line) => line.trim() !== ""); let xmlSteps = ``; for (let i = 0; i < stepsLines.length; i++) { const stepLine = stepsLines[i].trim(); - if (stepLine) { - // Split step and expected result by '|', fallback to default if not provided - const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim()); - const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/); - const stepText = stepMatch ? stepMatch[2] : stepPart; - const expectedText = expectedPart || "Verify step completes successfully"; - - xmlSteps += ` + const [stepPart, expectedPart] = stepLine.split("|").map((s) => s.trim()); + const stepMatch = stepPart.match(/^(\d+)\.\s*(.+)$/); + const stepText = stepMatch ? stepMatch[2] : stepPart; + const expectedText = expectedPart || "Verify step completes successfully"; + + xmlSteps += ` ${formatStepContent(stepText)} ${formatStepContent(expectedText)} `; - } } xmlSteps += ""; @@ -647,10 +502,11 @@ function escapeXml(unsafe: string): string { return "'"; case '"': return """; + /* istanbul ignore next */ default: return c; } }); } -export { Test_Plan_Tools, configureTestPlanTools }; +export { TEST_PLAN_TOOLS, configureTestPlanTools }; diff --git a/src/tools/work-items.ts b/src/tools/work-items.ts index 5a41a3c9..0a98b659 100644 --- a/src/tools/work-items.ts +++ b/src/tools/work-items.ts @@ -11,31 +11,16 @@ import { z } from "zod"; import { batchApiVersion, markdownCommentsApiVersion, getEnumKeys, safeEnumConvert, encodeFormattedValue } from "../utils.js"; import { elicitProject, elicitTeam } from "../shared/elicitations.js"; import { createExternalContentResponse } from "../shared/content-safety.js"; +import { getUserIdentityFromEmail } from "./auth.js"; const WORKITEM_TOOLS = { - my_work_items: "wit_my_work_items", - list_backlogs: "wit_list_backlogs", - list_backlog_work_items: "wit_list_backlog_work_items", - get_work_item: "wit_get_work_item", - get_work_items_batch_by_ids: "wit_get_work_items_batch_by_ids", - update_work_item: "wit_update_work_item", - create_work_item: "wit_create_work_item", - list_work_item_comments: "wit_list_work_item_comments", - list_work_item_revisions: "wit_list_work_item_revisions", - get_work_items_for_iteration: "wit_get_work_items_for_iteration", - add_work_item_comment: "wit_add_work_item_comment", - update_work_item_comment: "wit_update_work_item_comment", - add_child_work_items: "wit_add_child_work_items", - link_work_item_to_pull_request: "wit_link_work_item_to_pull_request", - get_work_item_type: "wit_get_work_item_type", - get_query: "wit_get_query", - get_query_results_by_id: "wit_get_query_results_by_id", - update_work_items_batch: "wit_update_work_items_batch", - work_items_link: "wit_work_items_link", - work_item_unlink: "wit_work_item_unlink", - add_artifact_link: "wit_add_artifact_link", - get_work_item_attachment: "wit_get_work_item_attachment", - query_by_wiql: "wit_query_by_wiql", + wit_work_item: "wit_work_item", + wit_query: "wit_query", + wit_backlog: "wit_backlog", + wit_work_item_attachment: "wit_work_item_attachment", + wit_work_item_write: "wit_work_item_write", + wit_work_item_comment_write: "wit_work_item_comment_write", + wit_work_item_link_write: "wit_work_item_link_write", }; function getLinkTypeFromName(name: string) { @@ -64,6 +49,8 @@ function getLinkTypeFromName(name: string) { return "Microsoft.VSTS.Common.Affects-Reverse"; case "artifact": return "ArtifactLink"; + case "hyperlink": + return "Hyperlink"; default: throw new Error(`Unknown link type: ${name}`); } @@ -78,377 +65,636 @@ function getArtifactLinkAttributeName(linkType: string): string { } } +function escapeHtml(value: string): string { + const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }; + + return value.replace(/[&<>"']/g, (character) => entities[character]); +} + +async function resolveCommentMentions( + text: string, + format: "Markdown" | "Html" | undefined, + tokenProvider: () => Promise, + connectionProvider: () => Promise, + userAgentProvider: () => string +): Promise { + const emailMatches = [...text.matchAll(/@<([^<>\s]+@[^<>\s]+)>/g)]; + if (emailMatches.length === 0) return text; + + const identities = new Map(); + for (const email of new Set(emailMatches.map((match) => match[1]))) { + try { + identities.set(email, await getUserIdentityFromEmail(email, tokenProvider, connectionProvider, userAgentProvider)); + } catch { + // Leave mentions unchanged when their identities cannot be resolved. + } + } + + return text.replace(/@<([^<>\s]+@[^<>\s]+)>/g, (mention, email: string) => { + const identity = identities.get(email); + if (!identity) return escapeHtml(mention); + return format === "Markdown" || format === undefined ? `@<${identity.id}>` : `@${escapeHtml(identity.displayName)}`; + }); +} + function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { + // --- wit_work_item ---------------------------------------------------------- server.tool( - WORKITEM_TOOLS.list_backlogs, - "Receive a list of backlogs for a given project and team. If a project or team is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_work_item, + "Retrieve work item data for a project. Use the action parameter to specify the operation.", { + action: z + .enum(["get", "get_batch", "list_comments", "my", "list_revisions", "list_for_iteration", "get_type"]) + .describe( + "The action to perform. Options: get (get a single work item by ID), get_batch (get multiple work items by IDs), list_comments (list comments on a work item), my (get work items relevant to the authenticated user), list_revisions (list revisions of a work item), list_for_iteration (list work items for a team iteration), get_type (get metadata for a work item type)." + ), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."), + id: z.coerce.number().min(1).optional().describe("Work item ID. Required for: get."), + ids: z.array(z.coerce.number().min(1)).optional().describe("Work item IDs. Required for: get_batch."), + workItemId: z.coerce.number().min(1).optional().describe("Work item ID. Required for: list_comments, list_revisions."), + fields: z.array(z.string()).optional().describe("Field names to include in the response. Used for: get, get_batch. For get, cannot be combined with expand."), + asOf: z.coerce.date().optional().describe("Retrieve the work item as of a specific date. Used for: get."), + expand: z + .enum(getEnumKeys(WorkItemExpand) as [string, ...string[]]) + .optional() + .describe("Expand options (None, Fields, Relations, Links, All). Used for: get, list_revisions. For get, cannot be combined with fields."), + top: z.coerce.number().optional().describe("Maximum number of results to return. Used for: list_comments, my, list_revisions. Defaults vary by action."), + includeCompleted: z.boolean().optional().default(false).describe("Include completed work items. Used for: my. Defaults to false."), + type: z.enum(["assignedtome", "myactivity"]).optional().describe("Type of work items to retrieve. Used for: my. Defaults to 'assignedtome'."), + skip: z.coerce.number().optional().describe("Number of results to skip for pagination. Used for: list_revisions."), + team: z.string().optional().describe("Team name or ID. Used for: list_for_iteration."), + iterationId: z.string().optional().describe("Iteration ID. Required for: list_for_iteration."), + workItemType: z.string().optional().describe("Work item type name. Required for: get_type."), }, - async ({ project, team }) => { + async ({ action, project, id, ids, workItemId, fields, asOf, expand, top, includeCompleted, type, skip, team, iterationId, workItemType }) => { try { const connection = await connectionProvider(); let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to list backlogs for."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; + + if (action === "get") { + if (!id) return { content: [{ type: "text", text: "id is required for get" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item from."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + let effectiveExpand = expand; + if (fields && fields.length > 0 && effectiveExpand != null) { + effectiveExpand = "none"; + } + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItem = await workItemApi.getWorkItem(id, fields, asOf, effectiveExpand as unknown as WorkItemExpand, resolvedProject); + return { content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }] }; } - let resolvedTeam = team; - if (!resolvedTeam) { - const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to list backlogs for."); - if ("response" in result) return result.response; - resolvedTeam = result.resolved; + if (action === "get_batch") { + if (!ids || ids.length === 0) return { content: [{ type: "text", text: "ids is required for get_batch" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workItemApi = await connection.getWorkItemTrackingApi(); + const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; + const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; + const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, resolvedProject); + + const identityFields = [ + "System.AssignedTo", + "System.CreatedBy", + "System.ChangedBy", + "System.AuthorizedAs", + "Microsoft.VSTS.Common.ActivatedBy", + "Microsoft.VSTS.Common.ResolvedBy", + "Microsoft.VSTS.Common.ClosedBy", + ]; + + if (workitems && Array.isArray(workitems)) { + workitems.forEach((item) => { + if (item.fields) { + identityFields.forEach((fieldName) => { + if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { + const identityField = item.fields[fieldName]; + const name = identityField.displayName || ""; + const email = identityField.uniqueName || ""; + item.fields[fieldName] = `${name} <${email}>`.trim(); + } + }); + } + }); + } + return { content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }] }; } - const workApi = await connection.getWorkApi(); - const teamContext = { project: resolvedProject, team: resolvedTeam }; - const backlogs = await workApi.getBacklogs(teamContext); + if (action === "list_comments") { + if (!workItemId) return { content: [{ type: "text", text: "workItemId is required for list_comments" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item comments for."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workItemApi = await connection.getWorkItemTrackingApi(); + const comments = await workItemApi.getComments(resolvedProject, workItemId, top ?? 50); + return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }], - }; + if (action === "my") { + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workApi = await connection.getWorkApi(); + const workItems = await workApi.getPredefinedQueryResults(resolvedProject, type ?? "assignedtome", top ?? 50, includeCompleted ?? false); + return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] }; + } + + if (action === "list_revisions") { + if (!workItemId) return { content: [{ type: "text", text: "workItemId is required for list_revisions" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item revisions for."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workItemApi = await connection.getWorkItemTrackingApi(); + const revisions = await workItemApi.getRevisions(workItemId, top ?? 50, skip, safeEnumConvert(WorkItemExpand, expand), resolvedProject); + + if (revisions && Array.isArray(revisions)) { + revisions.forEach((revision) => { + if (revision.fields) { + const revFields = revision.fields; + Object.keys(revFields).forEach((fieldName) => { + const fieldValue = revFields[fieldName]; + if ( + fieldValue && + typeof fieldValue === "object" && + !Array.isArray(fieldValue) && + "displayName" in fieldValue && + ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue) + ) { + delete fieldValue.url; + delete fieldValue._links; + delete fieldValue.id; + delete fieldValue.uniqueName; + delete fieldValue.imageUrl; + delete fieldValue.descriptor; + } + }); + } + }); + } + return { content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }] }; + } + + if (action === "list_for_iteration") { + if (!iterationId) return { content: [{ type: "text", text: "iterationId is required for list_for_iteration" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for iteration."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workApi = await connection.getWorkApi(); + const workItems = await workApi.getIterationWorkItems({ project: resolvedProject, team }, iterationId); + return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] }; + } + + if (action === "get_type") { + if (!workItemType) return { content: [{ type: "text", text: "workItemType is required for get_type" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item type from."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItemTypeInfo = await workItemApi.getWorkItemType(resolvedProject, workItemType); + return { content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }] }; + } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error listing backlogs: ${errorMessage}` }], - isError: true, + const msgs: Record = { + get: `Error retrieving work item: ${errorMessage}`, + get_batch: `Error retrieving work items batch: ${errorMessage}`, + list_comments: `Error listing work item comments: ${errorMessage}`, + my: `Error retrieving work items: ${errorMessage}`, + list_revisions: `Error listing work item revisions: ${errorMessage}`, + list_for_iteration: `Error retrieving work items for iteration: ${errorMessage}`, + get_type: `Error retrieving work item type: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); + // --- wit_query -------------------------------------------------------------- server.tool( - WORKITEM_TOOLS.list_backlog_work_items, - "Retrieve a list of backlogs of for a given project, team, and backlog category. If a project or team is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_query, + "Retrieve work item query data for a project. Use the action parameter to specify the operation.", { + action: z + .enum(["get", "get_results", "wiql"]) + .describe("The action to perform. Options: get (get a query by ID or path), get_results (run a saved query and return results), wiql (execute an ad-hoc WIQL query)."), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."), - backlogId: z.string().describe("The ID of the backlog category to retrieve work items from."), + query: z.string().optional().describe("The ID or path of the query. Required for: get."), + expand: z + .enum(getEnumKeys(QueryExpand) as [string, ...string[]]) + .optional() + .describe("Expand parameter to include additional details. Used for: get."), + depth: z.coerce.number().default(0).describe("Depth of expansion. Used for: get. Defaults to 0."), + includeDeleted: z.boolean().default(false).describe("Include deleted items. Used for: get. Defaults to false."), + useIsoDateFormat: z.boolean().default(false).describe("Use ISO date format in the response. Used for: get. Defaults to false."), + id: z.string().optional().describe("The ID of the saved query. Required for: get_results."), + team: z.string().optional().describe("Team name or ID. Used for: get_results, wiql."), + timePrecision: z.boolean().optional().describe("Include time precision in date fields. Used for: get_results, wiql."), + top: z.coerce.number().default(50).describe("Maximum number of results to return. Used for: get_results, wiql. Defaults to 50."), + responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete results (default), 'ids' returns only work item IDs. Used for: get_results."), + wiql: z.string().max(32768).optional().describe('The WIQL query string to execute. Required for: wiql. Example: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project".'), }, - async ({ project, team, backlogId }) => { + async ({ action, project, query, expand, depth, includeDeleted, useIsoDateFormat, id, team, timePrecision, top, responseType, wiql }) => { try { const connection = await connectionProvider(); let resolvedProject = project; if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to list backlog work items for."); + const result = await elicitProject(server, connection, `Select the Azure DevOps project for ${action}.`); if ("response" in result) return result.response; resolvedProject = result.resolved; } - let resolvedTeam = team; - if (!resolvedTeam) { - const result = await elicitTeam(server, connection, resolvedProject, "Select the Azure DevOps team to list backlog work items for."); - if ("response" in result) return result.response; - resolvedTeam = result.resolved; + if (action === "get") { + if (!query) return { content: [{ type: "text", text: "query is required for get" }], isError: true }; + const workItemApi = await connection.getWorkItemTrackingApi(); + const queryDetails = await workItemApi.getQuery(resolvedProject, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); + return { content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }] }; } - const workApi = await connection.getWorkApi(); - const teamContext = { project: resolvedProject, team: resolvedTeam }; + if (action === "get_results") { + if (!id) return { content: [{ type: "text", text: "id is required for get_results" }], isError: true }; + const workItemApi = await connection.getWorkItemTrackingApi(); + const teamContext = { project: resolvedProject, team }; + const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); + if (responseType === "ids") { + const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((wid): wid is number => wid !== undefined) || []; + return { content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }] }; + } + return { content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }] }; + } - const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); + if (action === "wiql") { + if (!wiql) return { content: [{ type: "text", text: "wiql is required for wiql" }], isError: true }; + const workItemApi = await connection.getWorkItemTrackingApi(); + const teamContext = { project: resolvedProject, team }; + const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top); + return createExternalContentResponse(queryResult, "wiql query results"); + } - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error listing backlog work items: ${errorMessage}` }], - isError: true, + const msgs: Record = { + get: `Error retrieving query: ${errorMessage}`, + get_results: `Error retrieving query results: ${errorMessage}`, + wiql: `Error executing WIQL query: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); + // --- wit_backlog ------------------------------------------------------------ server.tool( - WORKITEM_TOOLS.my_work_items, - "Retrieve a list of work items relevant to the authenticated user. If a project is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_backlog, + "Retrieve backlog data for a project and team. Use the action parameter to specify the operation.", { + action: z + .enum(["list", "list_work_items", "reorder"]) + .describe( + "The action to perform. Options: list (list backlog levels for a team), list_work_items (list work items in a specific backlog level), reorder (move work items to a new position in a backlog or iteration)." + ), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - type: z.enum(["assignedtome", "myactivity"]).default("assignedtome").describe("The type of work items to retrieve. Defaults to 'assignedtome'."), - top: z.coerce.number().default(50).describe("The maximum number of work items to return. Defaults to 50."), - includeCompleted: z.boolean().default(false).describe("Whether to include completed work items. Defaults to false."), + team: z.string().optional().describe("The name or ID of the Azure DevOps team. Reuse from prior context if already known. If not provided, a team selection prompt will be shown."), + backlogId: z.string().optional().describe("The ID of the backlog category to retrieve work items from. Required for: list_work_items."), + ids: z.array(z.coerce.number().int().min(1)).min(1).optional().describe("The IDs of the work items to reorder. Required for: reorder."), + previousId: z.coerce + .number() + .int() + .min(0) + .optional() + .describe("The ID of the work item that should be before the reordered items. Use 0 to specify the beginning of the list. Optional for: reorder."), + nextId: z.coerce.number().int().min(0).optional().describe("The ID of the work item that should be after the reordered items. Use 0 to specify the end of the list. Optional for: reorder."), + parentId: z.coerce.number().int().min(0).optional().describe("The parent ID for all work items involved in the operation. Use 0 to indicate the items have no parent. Optional for: reorder."), + iterationPath: z.string().optional().describe("The iteration path for the reorder operation. Used when reordering items in an iteration backlog. Optional for: reorder."), + iterationId: z.string().optional().describe("The iteration ID. When provided, reorder items in that iteration instead of the team backlog. Used for: reorder."), }, - async ({ project, type, top, includeCompleted }) => { + async ({ action, project, team, backlogId, ids, previousId, nextId, parentId, iterationPath, iterationId }) => { try { const connection = await connectionProvider(); let resolvedProject = project; if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for."); + const label = action === "list" ? "list backlogs" : "list backlog work items"; + const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label} for.`); if ("response" in result) return result.response; resolvedProject = result.resolved; } + let resolvedTeam = team; + if (!resolvedTeam) { + const label = action === "list" ? "list backlogs" : "list backlog work items"; + const result = await elicitTeam(server, connection, resolvedProject, `Select the Azure DevOps team to ${label} for.`); + if ("response" in result) return result.response; + resolvedTeam = result.resolved; + } + const workApi = await connection.getWorkApi(); + const teamContext = { project: resolvedProject, team: resolvedTeam }; - const workItems = await workApi.getPredefinedQueryResults(resolvedProject, type, top, includeCompleted); + if (action === "list") { + const backlogs = await workApi.getBacklogs(teamContext); + return { content: [{ type: "text", text: JSON.stringify(backlogs, null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving work items: ${errorMessage}` }], - isError: true, - }; - } - } - ); + if (action === "list_work_items") { + if (!backlogId) return { content: [{ type: "text", text: "backlogId is required for list_work_items" }], isError: true }; + const workItems = await workApi.getBacklogLevelWorkItems(teamContext, backlogId); + return { content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }] }; + } - server.tool( - WORKITEM_TOOLS.get_work_items_batch_by_ids, - "Retrieve list of work items by IDs in batch. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - ids: z.array(z.coerce.number().min(1)).describe("The IDs of the work items to retrieve."), - fields: z.array(z.string()).optional().describe("Optional list of fields to include in the response. If not provided, a hardcoded default set of fields will be used."), - }, - async ({ project, ids, fields }) => { - try { - const connection = await connectionProvider(); + if (action === "reorder") { + if (!ids?.length) return { content: [{ type: "text", text: "ids is required for reorder" }], isError: true }; - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } + const operation = { ids, previousId, nextId, parentId, iterationPath }; + const reorderedItems = iterationId ? await workApi.reorderIterationWorkItems(operation, teamContext, iterationId) : await workApi.reorderBacklogWorkItems(operation, teamContext); - const workItemApi = await connection.getWorkItemTrackingApi(); - const defaultFields = ["System.Id", "System.WorkItemType", "System.Title", "System.State", "System.Parent", "System.Tags", "Microsoft.VSTS.Common.StackRank", "System.AssignedTo"]; - - // If no fields are provided, use the default set of fields - const fieldsToUse = !fields || fields.length === 0 ? defaultFields : fields; - - const workitems = await workItemApi.getWorkItemsBatch({ ids, fields: fieldsToUse }, resolvedProject); - - // List of identity fields that need to be transformed from objects to formatted strings - const identityFields = [ - "System.AssignedTo", - "System.CreatedBy", - "System.ChangedBy", - "System.AuthorizedAs", - "Microsoft.VSTS.Common.ActivatedBy", - "Microsoft.VSTS.Common.ResolvedBy", - "Microsoft.VSTS.Common.ClosedBy", - ]; - - // Format identity fields to include displayName and uniqueName - // Removing the identity object as the response. It's too much and not needed - if (workitems && Array.isArray(workitems)) { - workitems.forEach((item) => { - if (item.fields) { - identityFields.forEach((fieldName) => { - if (item.fields && item.fields[fieldName] && typeof item.fields[fieldName] === "object") { - const identityField = item.fields[fieldName]; - const name = identityField.displayName || ""; - const email = identityField.uniqueName || ""; - item.fields[fieldName] = `${name} <${email}>`.trim(); - } - }); - } - }); + return { content: [{ type: "text", text: JSON.stringify(reorderedItems, null, 2) }] }; } - return { - content: [{ type: "text", text: JSON.stringify(workitems, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving work items batch: ${errorMessage}` }], - isError: true, + const msgs: Record = { + list: `Error listing backlogs: ${errorMessage}`, + list_work_items: `Error listing backlog work items: ${errorMessage}`, + reorder: `Error reordering backlog work items: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); + // --- wit_work_item_attachment ----------------------------------------------- server.tool( - WORKITEM_TOOLS.get_work_item, - "Get a single work item by ID. If a project is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_work_item_attachment, + "Download a work item attachment by its ID. By default returns the content as a base64-encoded resource. If savePath is provided, saves the file locally to that directory and returns the file path instead. Useful for viewing images (e.g. screenshots) or other files attached to work items such as bugs. If a project is not specified, you will be prompted to select one.", { - id: z.coerce.number().min(1).describe("The ID of the work item to retrieve."), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - fields: z - .array(z.string()) - .optional() - .describe("Optional list of fields to include in the response. If not provided, all fields will be returned. Cannot be used together with the expand parameter."), - asOf: z.coerce.date().optional().describe("Optional date string to retrieve the work item as of a specific time. If not provided, the current state will be returned."), - expand: z - .enum(["all", "fields", "links", "none", "relations"]) - .describe("Optional expand parameter to include additional details in the response. Cannot be used together with the fields parameter.") + attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"), + fileName: z.string().optional().describe("The file name of the attachment, e.g. 'screenshot.png'. Used to determine the MIME type or the saved file's name."), + savePath: z + .string() .optional() .describe( - "Expand options include 'All', 'Fields', 'Links', 'None', and 'Relations'. Relations can be used to get child workitems. Defaults to 'None'. Cannot be used together with the fields parameter." + "Optional local directory path where the file should be saved. Must be a relative path (e.g. 'temp' or 'downloads/attachments'); absolute paths and path traversals are not allowed. If provided, saves the attachment to this directory and returns the file path. If omitted, returns the content as a base64-encoded resource." ), }, - async ({ id, project, fields, asOf, expand }) => { + async ({ project, attachmentId, fileName, savePath }) => { + const isAbsolutePath = (value: string) => path.posix.isAbsolute(value) || path.win32.isAbsolute(value); + const hasDriveLetter = (value: string) => /^[a-zA-Z]:/.test(value); + + if (savePath !== undefined && (savePath.includes("..") || isAbsolutePath(savePath) || hasDriveLetter(savePath))) { + throw new Error("Invalid savePath: absolute paths and path traversals are not allowed."); + } + + if (fileName !== undefined && fileName.includes("..")) { + throw new Error("Invalid fileName: path traversal is not allowed."); + } + try { const connection = await connectionProvider(); let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item from."); - + const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item attachment from."); if ("response" in result) return result.response; resolvedProject = result.resolved; } - // The Azure DevOps API does not support using expand and fields together. - // When both are provided, prefer fields as it is the more specific selection. - if (fields && fields.length > 0 && expand != null) { - expand = "none"; - } - const workItemApi = await connection.getWorkItemTrackingApi(); - const workItem = await workItemApi.getWorkItem(id, fields, asOf, expand as unknown as WorkItemExpand, resolvedProject); + const stream = await workItemApi.getAttachmentContent(attachmentId, fileName, resolvedProject); - return { - content: [{ type: "text", text: JSON.stringify(workItem, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const chunks: Buffer[] = []; + await new Promise((resolve, reject) => { + stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); + stream.on("end", resolve); + stream.on("error", reject); + }); - return { - content: [{ type: "text", text: `Error retrieving work item: ${errorMessage}` }], - isError: true, - }; - } - } - ); + const buffer = Buffer.concat(chunks); - server.tool( - WORKITEM_TOOLS.list_work_item_comments, - "Retrieve list of comments for a work item by ID. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve comments for."), - top: z.coerce.number().default(50).describe("Optional number of comments to retrieve. Defaults to all comments."), - }, - async ({ project, workItemId, top }) => { - try { - const connection = await connectionProvider(); + if (savePath) { + const resolvedFileName = fileName ?? attachmentId; + const localFilePath = path.join(savePath, resolvedFileName); - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item comments for."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; + if (fs.existsSync(localFilePath)) { + throw new Error(`File already exists: ${localFilePath}`); + } + + fs.writeFileSync(localFilePath, buffer); + + return { + content: [{ type: "text", text: `Attachment saved to: ${localFilePath}` }], + }; } - const workItemApi = await connection.getWorkItemTrackingApi(); - const comments = await workItemApi.getComments(resolvedProject, workItemId, top); + const mimeType = getMimeType(fileName); + + if (mimeType.startsWith("text/")) { + return { + content: [{ type: "text", text: buffer.toString("utf-8") }], + }; + } + const base64Data = buffer.toString("base64"); return { - content: [{ type: "text", text: JSON.stringify(comments, null, 2) }], + content: [ + { + type: "resource", + resource: { + uri: `data:${mimeType};base64,${base64Data}`, + mimeType, + blob: base64Data, + }, + }, + ], }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; return { - content: [{ type: "text", text: `Error listing work item comments: ${errorMessage}` }], + content: [{ type: "text", text: `Error retrieving work item attachment: ${errorMessage}` }], isError: true, }; } } ); + // --- wit_work_item_write ---------------------------------------------------- server.tool( - WORKITEM_TOOLS.add_work_item_comment, - "Add comment to a work item by ID. If a project is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_work_item_write, + "Write operations for work items. Use the action parameter to specify the operation.", { + action: z + .enum(["create", "update", "update_batch", "add_child"]) + .describe( + "The action to perform. Options: create (create a new work item), update (update fields on a single work item), update_batch (update multiple work items in one call), add_child (create child work items under a parent)." + ), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemId: z.coerce.number().min(1).describe("The ID of the work item to add a comment to."), - comment: z.string().describe("The text of the comment to add to the work item."), - format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("The format of the comment text, e.g., 'Markdown', 'Html'. Optional, defaults to 'Markdown'."), + id: z.coerce.number().min(1).optional().describe("Work item ID to update. Required for: update."), + workItemType: z.string().optional().describe("The type of work item. Required for: create, add_child."), + fields: z + .array( + z.object({ + name: z.string().describe("The field name, e.g. 'System.Title'."), + value: z.string().describe("The field value."), + format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."), + }) + ) + .optional() + .describe("Field values to set on the work item. Required for: create."), + updates: z + .array( + z.object({ + op: z + .string() + .transform((val) => val.toLowerCase()) + .pipe(z.enum(["add", "replace", "remove"])) + .default("add") + .describe("The operation to perform."), + path: z.string().describe("The field path, e.g. '/fields/System.Title'."), + value: z.string().describe("The new value for the field."), + }) + ) + .optional() + .describe("Field updates for a single work item. Required for: update."), + batchUpdates: z + .array( + z.object({ + op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform."), + id: z.coerce.number().min(1).describe("The work item ID to update."), + path: z.string().describe("The field path, e.g. '/fields/System.Title'."), + value: z.string().describe("The new value for the field."), + format: z.enum(["Html", "Markdown"]).optional().describe("Format for large text fields. Optional."), + }) + ) + .optional() + .describe("Updates for multiple work items. Required for: update_batch."), + parentId: z.coerce.number().min(1).optional().describe("The ID of the parent work item. Required for: add_child."), + items: z + .array( + z.object({ + title: z.string().describe("The title of the child work item."), + description: z.string().describe("The description of the child work item."), + format: z.enum(["Markdown", "Html"]).default("Markdown").describe("Format for the description. Defaults to 'Markdown'."), + areaPath: z.string().optional().describe("Optional area path for the child work item."), + iterationPath: z.string().optional().describe("Optional iteration path for the child work item."), + }) + ) + .optional() + .describe("Child work items to create. Required for: add_child."), }, - async ({ project, workItemId, comment, format }) => { + async ({ action, project, id, workItemType, fields, updates, batchUpdates, parentId, items }) => { try { const connection = await connectionProvider(); let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to add a work item comment in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); + if (action === "create") { + if (!workItemType) return { content: [{ type: "text", text: "workItemType is required for create" }], isError: true }; + if (!fields || fields.length === 0) return { content: [{ type: "text", text: "fields is required for create" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to create the work item in."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } - const body = { - text: comment, - }; + const workItemApi = await connection.getWorkItemTrackingApi(); - const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1; - const response = await fetch( - `${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, - { - method: "POST", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - } - ); + const document = fields.map(({ name, value, format }) => ({ + op: "add", + path: `/fields/${name}`, + value: encodeFormattedValue(value, format), + })); - if (!response.ok) { - throw new Error(`Failed to add a work item comment: ${response.statusText}}`); - } + fields.forEach(({ name, format }) => { + if (format === "Markdown") { + document.push({ + op: "add", + path: `/multilineFieldsFormat/${name}`, + value: "Markdown", + }); + } + }); - const comments = await response.text(); + const newWorkItem = await workItemApi.createWorkItem(null, document, resolvedProject, workItemType); - return { - content: [{ type: "text", text: comments }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error adding work item comment: ${errorMessage}` }], - isError: true, - }; - } - } - ); + if (!newWorkItem) { + return { content: [{ type: "text", text: "Work item was not created" }], isError: true }; + } - server.tool( - WORKITEM_TOOLS.update_work_item_comment, - "Update an existing comment on a work item by ID. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemId: z.coerce.number().min(1).describe("The ID of the work item."), - commentId: z.coerce.number().min(1).describe("The ID of the comment to update."), - text: z.string().describe("The updated comment text."), - format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("The format of the comment text, e.g., 'Markdown', 'Html'. Optional, defaults to 'Markdown'."), - }, - async ({ project, workItemId, commentId, text, format }) => { - try { - const connection = await connectionProvider(); + return { content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }] }; + } - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to update the work item comment in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; + if (action === "update") { + if (!id) return { content: [{ type: "text", text: "id is required for update" }], isError: true }; + if (!updates || updates.length === 0) return { content: [{ type: "text", text: "updates is required for update" }], isError: true }; + + const workItemApi = await connection.getWorkItemTrackingApi(); + const apiUpdates = updates.map((update) => ({ ...update, op: update.op })); + const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); + return { content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }] }; } - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - const body: Record = { text }; - const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1; + if (action === "update_batch") { + if (!batchUpdates || batchUpdates.length === 0) return { content: [{ type: "text", text: "batchUpdates is required for update_batch" }], isError: true }; + + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + const uniqueIds = Array.from(new Set(batchUpdates.map((update) => update.id))); + + const body = uniqueIds.map((uid) => { + const workItemUpdates = batchUpdates.filter((update) => update.id === uid); + const operations = workItemUpdates.map(({ op, path: fieldPath, value, format }) => ({ + op: op, + path: fieldPath, + value: encodeFormattedValue(value, format), + })); + + workItemUpdates.forEach(({ path: fieldPath, format }) => { + if (format === "Markdown") { + operations.push({ + op: "Add", + path: `/multilineFieldsFormat${fieldPath.replace("/fields", "")}`, + value: "Markdown", + }); + } + }); + + return { + method: "PATCH", + uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`, + headers: { "Content-Type": "application/json-patch+json" }, + body: operations, + }; + }); - const response = await fetch( - `${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, - { + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { method: "PATCH", headers: { "Authorization": `Bearer ${accessToken}`, @@ -456,871 +702,241 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< "User-Agent": userAgentProvider(), }, body: JSON.stringify(body), - } - ); - - if (!response.ok) { - throw new Error(`Failed to update work item comment: ${response.statusText}`); - } - - const updatedComment = await response.text(); - - return { - content: [{ type: "text", text: updatedComment }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error updating work item comment: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.list_work_item_revisions, - "Retrieve list of revisions for a work item by ID. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemId: z.coerce.number().min(1).describe("The ID of the work item to retrieve revisions for."), - top: z.coerce.number().default(50).describe("Optional number of revisions to retrieve. If not provided, all revisions will be returned."), - skip: z.coerce.number().optional().describe("Optional number of revisions to skip for pagination. Defaults to 0."), - expand: z - .enum(getEnumKeys(WorkItemExpand) as [string, ...string[]]) - .default("None") - .optional() - .describe("Optional expand parameter to include additional details. Defaults to 'None'."), - }, - async ({ project, workItemId, top, skip, expand }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to list work item revisions for."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const workItemApi = await connection.getWorkItemTrackingApi(); - const revisions = await workItemApi.getRevisions(workItemId, top, skip, safeEnumConvert(WorkItemExpand, expand), resolvedProject); - - // Dynamically clean up identity objects in revision fields - // Identity objects typically have properties like displayName, url, _links, id, uniqueName, imageUrl, descriptor - if (revisions && Array.isArray(revisions)) { - revisions.forEach((revision) => { - if (revision.fields) { - const fields = revision.fields; - Object.keys(fields).forEach((fieldName) => { - const fieldValue = fields[fieldName]; - // Check if this is an identity object by looking for common identity properties - if ( - fieldValue && - typeof fieldValue === "object" && - !Array.isArray(fieldValue) && - "displayName" in fieldValue && - ("url" in fieldValue || "_links" in fieldValue || "uniqueName" in fieldValue) - ) { - // Remove unwanted properties from identity objects - delete fieldValue.url; - delete fieldValue._links; - delete fieldValue.id; - delete fieldValue.uniqueName; - delete fieldValue.imageUrl; - delete fieldValue.descriptor; - } - }); - } }); - } - - return { - content: [{ type: "text", text: JSON.stringify(revisions, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error listing work item revisions: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.add_child_work_items, - "Create one or many child work items from a parent by work item type and parent id. If a project is not specified, you will be prompted to select one.", - { - parentId: z.coerce.number().min(1).describe("The ID of the parent work item to create a child work item under."), - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemType: z.string().describe("The type of the child work item to create."), - items: z.array( - z.object({ - title: z.string().describe("The title of the child work item."), - description: z.string().describe("The description of the child work item."), - format: z.enum(["Markdown", "Html"]).default("Markdown").describe("Format for the description on the child work item, e.g., 'Markdown', 'Html'. Defaults to 'Markdown'."), - areaPath: z.string().optional().describe("Optional area path for the child work item."), - iterationPath: z.string().optional().describe("Optional iteration path for the child work item."), - }) - ), - }, - async ({ parentId, project, workItemType, items }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to create child work items in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } - if (items.length > 50) { - return { - content: [{ type: "text", text: `A maximum of 50 child work items can be created in a single call.` }], - isError: true, - }; + const result = await response.json(); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; } - const body = items.map((item, x) => { - const encodedDescription = encodeFormattedValue(item.description, item.format); - - const ops = [ - { - op: "add", - path: "/id", - value: `-${x + 1}`, - }, - { - op: "add", - path: "/fields/System.Title", - value: item.title, - }, - { - op: "add", - path: "/fields/System.Description", - value: encodedDescription, - }, - { - op: "add", - path: "/fields/Microsoft.VSTS.TCM.ReproSteps", - value: encodedDescription, - }, - { - op: "add", - path: "/relations/-", - value: { - rel: "System.LinkTypes.Hierarchy-Reverse", - url: `${connection.serverUrl}/${resolvedProject}/_apis/wit/workItems/${parentId}`, - }, - }, - ]; - - if (item.areaPath && item.areaPath.trim().length > 0) { - ops.push({ - op: "add", - path: "/fields/System.AreaPath", - value: item.areaPath, - }); - } - - if (item.iterationPath && item.iterationPath.trim().length > 0) { - ops.push({ - op: "add", - path: "/fields/System.IterationPath", - value: item.iterationPath, - }); + if (action === "add_child") { + if (!parentId) return { content: [{ type: "text", text: "parentId is required for add_child" }], isError: true }; + if (!workItemType) return { content: [{ type: "text", text: "workItemType is required for add_child" }], isError: true }; + if (!items || items.length === 0) return { content: [{ type: "text", text: "items is required for add_child" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to create child work items in."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; } - if (item.format && item.format === "Markdown") { - ops.push({ - op: "add", - path: "/multilineFieldsFormat/System.Description", - value: item.format, - }); - - ops.push({ - op: "add", - path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", - value: item.format, - }); + if (items.length > 50) { + return { content: [{ type: "text", text: "A maximum of 50 child work items can be created in a single call." }], isError: true }; } - return { - method: "PATCH", - uri: `/${encodeURIComponent(resolvedProject)}/_apis/wit/workitems/$${encodeURIComponent(workItemType)}?api-version=${batchApiVersion}`, - headers: { - "Content-Type": "application/json-patch+json", - }, - body: ops, - }; - }); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } - - const result = await response.json(); - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error creating child work items: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.link_work_item_to_pull_request, - "Link a single work item to an existing pull request.", - { - projectId: z.string().describe("The project ID of the Azure DevOps project (note: project name is not valid)."), - repositoryId: z.string().describe("The ID of the repository containing the pull request. Do not use the repository name here, use the ID instead."), - pullRequestId: z.coerce.number().min(1).describe("The ID of the pull request to link to."), - workItemId: z.coerce.number().min(1).describe("The ID of the work item to link to the pull request."), - pullRequestProjectId: z.string().optional().describe("The project ID containing the pull request. If not provided, defaults to the work item's project ID (for same-project linking)."), - }, - async ({ projectId, repositoryId, pullRequestId, workItemId, pullRequestProjectId }) => { - try { - const connection = await connectionProvider(); - const workItemTrackingApi = await connection.getWorkItemTrackingApi(); - - // Create artifact link relation using vstfs format - // Format: vstfs:///Git/PullRequestId/{project}/{repositoryId}/{pullRequestId} - const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId; - const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`; - const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`; - - // Use the PATCH document format for adding a relation - const patchDocument = [ - { - op: "add", - path: "/relations/-", - value: { - rel: "ArtifactLink", - url: vstfsUrl, - attributes: { - name: "Pull Request", - }, - }, - }, - ]; - - // Use the WorkItem API to update the work item with the new relation - const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId); - - if (!workItem) { - return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; - } - - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - workItemId, - pullRequestId, - success: true, + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + + const body = items.map((item, x) => { + const encodedDescription = encodeFormattedValue(item.description, item.format); + + const ops: { op: string; path: string; value: unknown }[] = [ + { op: "add", path: "/id", value: `-${x + 1}` }, + { op: "add", path: "/fields/System.Title", value: item.title }, + { op: "add", path: "/fields/System.Description", value: encodedDescription }, + { op: "add", path: "/fields/Microsoft.VSTS.TCM.ReproSteps", value: encodedDescription }, + { + op: "add", + path: "/relations/-", + value: { + rel: "System.LinkTypes.Hierarchy-Reverse", + url: `${connection.serverUrl}/${resolvedProject}/_apis/wit/workItems/${parentId}`, }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error linking work item to pull request: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_items_for_iteration, - "Retrieve a list of work items for a specified iteration. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), - iterationId: z.string().describe("The ID of the iteration to retrieve work items for."), - }, - async ({ project, team, iterationId }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve work items for iteration."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const workApi = await connection.getWorkApi(); - - //get the work items for the current iteration - const workItems = await workApi.getIterationWorkItems({ project: resolvedProject, team }, iterationId); - - return { - content: [{ type: "text", text: JSON.stringify(workItems, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving work items for iteration: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.update_work_item, - "Update a work item by ID with specified fields.", - { - id: z.coerce.number().min(1).describe("The ID of the work item to update."), - updates: z - .array( - z.object({ - op: z - .string() - .transform((val) => val.toLowerCase()) - .pipe(z.enum(["add", "replace", "remove"])) - .default("add") - .describe("The operation to perform on the field."), - path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), - value: z.string().describe("The new value for the field. This is required for 'Add' and 'Replace' operations, and should be omitted for 'Remove' operations."), - }) - ) - .describe("An array of field updates to apply to the work item."), - }, - async ({ id, updates }) => { - try { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - - // Convert operation names to lowercase for API - const apiUpdates = updates.map((update) => ({ - ...update, - op: update.op, - })); - - const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id); - - return { - content: [{ type: "text", text: JSON.stringify(updatedWorkItem, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error updating work item: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_item_type, - "Get a specific work item type. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemType: z.string().describe("The name of the work item type to retrieve."), - }, - async ({ project, workItemType }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item type from."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const workItemApi = await connection.getWorkItemTrackingApi(); - - const workItemTypeInfo = await workItemApi.getWorkItemType(resolvedProject, workItemType); - - return { - content: [{ type: "text", text: JSON.stringify(workItemTypeInfo, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving work item type: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.create_work_item, - "Create a new work item in a specified project and work item type. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - workItemType: z.string().describe("The type of work item to create, e.g., 'Task', 'Bug', etc."), - fields: z - .array( - z.object({ - name: z.string().describe("The name of the field, e.g., 'System.Title'."), - value: z.string().describe("The value of the field."), - format: z.enum(["Html", "Markdown"]).optional().describe("the format of the field value, e.g., 'Html', 'Markdown'. Optional, defaults to 'Markdown'."), - }) - ) - .describe("A record of field names and values to set on the new work item. Each fild is the field name and each value is the corresponding value to set for that field."), - }, - async ({ project, workItemType, fields }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to create the work item in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const workItemApi = await connection.getWorkItemTrackingApi(); - - const document = fields.map(({ name, value, format }) => ({ - op: "add", - path: `/fields/${name}`, - value: encodeFormattedValue(value, format), - })); - - // Check if any field has format === "Markdown" and add the multilineFieldsFormat operation - // this should only happen for large text fields, but since we don't know by field name, lets assume if the users - // passes a value longer than 100 characters, then we can set the format to Markdown - fields.forEach(({ name, value, format }) => { - if (value.length > 100 && format === "Markdown") { - document.push({ - op: "add", - path: `/multilineFieldsFormat/${name}`, - value: "Markdown", - }); - } - }); - - const newWorkItem = await workItemApi.createWorkItem(null, document, resolvedProject, workItemType); - - if (!newWorkItem) { - return { content: [{ type: "text", text: "Work item was not created" }], isError: true }; - } - - return { - content: [{ type: "text", text: JSON.stringify(newWorkItem, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error creating work item: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_query, - "Get a query by its ID or path. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - query: z.string().describe("The ID or path of the query to retrieve."), - expand: z - .enum(getEnumKeys(QueryExpand) as [string, ...string[]]) - .optional() - .describe("Optional expand parameter to include additional details in the response. Defaults to 'None'."), - depth: z.coerce.number().default(0).describe("Optional depth parameter to specify how deep to expand the query. Defaults to 0."), - includeDeleted: z.boolean().default(false).describe("Whether to include deleted items in the query results. Defaults to false."), - useIsoDateFormat: z.boolean().default(false).describe("Whether to use ISO date format in the response. Defaults to false."), - }, - async ({ project, query, expand, depth, includeDeleted, useIsoDateFormat }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the query from."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const workItemApi = await connection.getWorkItemTrackingApi(); - - const queryDetails = await workItemApi.getQuery(resolvedProject, query, safeEnumConvert(QueryExpand, expand), depth, includeDeleted, useIsoDateFormat); - - return { - content: [{ type: "text", text: JSON.stringify(queryDetails, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving query: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_query_results_by_id, - "Retrieve the results of a work item query given the query ID. Supports full or IDs-only response types.", - { - id: z.string().describe("The ID of the query to retrieve results for."), - project: z.string().optional().describe("The name or ID of the Azure DevOps project. If not provided, the default project will be used."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team will be used."), - timePrecision: z.boolean().optional().describe("Whether to include time precision in the results. Defaults to false."), - top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."), - responseType: z.enum(["full", "ids"]).default("full").describe("Response type: 'full' returns complete query results (default), 'ids' returns only work item IDs for reduced payload size."), - }, - async ({ id, project, team, timePrecision, top, responseType }) => { - try { - const connection = await connectionProvider(); - const workItemApi = await connection.getWorkItemTrackingApi(); - const teamContext = { project, team }; - const queryResult = await workItemApi.queryById(id, teamContext, timePrecision, top); - - // If ids mode, extract and return only the IDs - if (responseType === "ids") { - const ids = queryResult.workItems?.map((workItem) => workItem.id).filter((id): id is number => id !== undefined) || []; - return { - content: [{ type: "text", text: JSON.stringify({ ids, count: ids.length }, null, 2) }], - }; - } - - // Default: return full query results - return { - content: [{ type: "text", text: JSON.stringify(queryResult, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving query results: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.update_work_items_batch, - "Update work items in batch", - { - updates: z - .array( - z.object({ - op: z.enum(["Add", "Replace", "Remove"]).default("Add").describe("The operation to perform on the field."), - id: z.coerce.number().min(1).describe("The ID of the work item to update."), - path: z.string().describe("The path of the field to update, e.g., '/fields/System.Title'."), - value: z.string().describe("The new value for the field. This is required for 'add' and 'replace' operations, and should be omitted for 'remove' operations."), - format: z - .enum(["Html", "Markdown"]) - .optional() - .describe("The format of the field value. Only to be used for large text fields. e.g., 'Html', 'Markdown'. Optional, defaults to 'Markdown'."), - }) - ) - .describe("An array of updates to apply to work items. Each update should include the operation (op), work item ID (id), field path (path), and new value (value)."), - }, - async ({ updates }) => { - try { - const connection = await connectionProvider(); - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); + }, + ]; - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); + if (item.areaPath && item.areaPath.trim().length > 0) { + ops.push({ op: "add", path: "/fields/System.AreaPath", value: item.areaPath }); + } - const body = uniqueIds.map((id) => { - const workItemUpdates = updates.filter((update) => update.id === id); - const operations = workItemUpdates.map(({ op, path, value, format }) => ({ - op: op, - path: path, - value: encodeFormattedValue(value, format), - })); + if (item.iterationPath && item.iterationPath.trim().length > 0) { + ops.push({ op: "add", path: "/fields/System.IterationPath", value: item.iterationPath }); + } - // Add format operations for Markdown fields - workItemUpdates.forEach(({ path, value, format }) => { - if (format === "Markdown" && value && value.length > 100) { - operations.push({ - op: "Add", - path: `/multilineFieldsFormat${path.replace("/fields", "")}`, - value: "Markdown", - }); + if (item.format && item.format === "Markdown") { + ops.push({ op: "add", path: "/multilineFieldsFormat/System.Description", value: item.format }); + ops.push({ op: "add", path: "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", value: item.format }); } + + return { + method: "PATCH", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uri: `/${encodeURIComponent(resolvedProject!)}/_apis/wit/workitems/$${encodeURIComponent(workItemType!)}?api-version=${batchApiVersion}`, + headers: { "Content-Type": "application/json-patch+json" }, + body: ops, + }; }); - return { + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, headers: { - "Content-Type": "application/json-patch+json", + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), }, - body: operations, - }; - }); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } - - const result = await response.json(); - - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error updating work items in batch: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.work_items_link, - "Link work items together in batch. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - updates: z - .array( - z.object({ - id: z.coerce.number().min(1).describe("The ID of the work item to update."), - linkToId: z.coerce.number().min(1).describe("The ID of the work item to link to."), - type: z - .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by"]) - .default("related") - .describe( - "Type of link to create between the work items. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', and 'affected by'. Defaults to 'related'." - ), - comment: z.string().optional().describe("Optional comment to include with the link. This can be used to provide additional context for the link being created."), - }) - ) - .describe(""), - }, - async ({ project, updates }) => { - try { - const connection = await connectionProvider(); - - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to link work items in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - - // Extract unique IDs from the updates array - const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - - const body = uniqueIds.map((id) => ({ - method: "PATCH", - uri: `/_apis/wit/workitems/${id}?api-version=${batchApiVersion}`, - headers: { - "Content-Type": "application/json-patch+json", - }, - body: updates - .filter((update) => update.id === id) - .map(({ linkToId, type, comment }) => ({ - op: "add", - path: "/relations/-", - value: { - rel: `${getLinkTypeFromName(type)}`, - url: `${orgUrl}/${resolvedProject}/_apis/wit/workItems/${linkToId}`, - attributes: { - comment: comment || "", - }, - }, - })), - })); - - const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { - method: "PATCH", - headers: { - "Authorization": `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": userAgentProvider(), - }, - body: JSON.stringify(body), - }); + body: JSON.stringify(body), + }); - if (!response.ok) { - throw new Error(`Failed to update work items in batch: ${response.statusText}`); - } + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } - const result = await response.json(); + const result = await response.json(); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } - return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error linking work items: ${errorMessage}` }], - isError: true, + const msgs: Record = { + create: `Error creating work item: ${errorMessage}`, + update: `Error updating work item: ${errorMessage}`, + update_batch: `Error updating work items in batch: ${errorMessage}`, + add_child: `Error creating child work items: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); + // --- wit_work_item_comment_write -------------------------------------------- server.tool( - WORKITEM_TOOLS.work_item_unlink, - "Remove one or many links from a single work item. If a project is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_work_item_comment_write, + "Write operations for work item comments. Use the action parameter to specify the operation.", { + action: z.enum(["add", "update"]).describe("The action to perform. Options: add (add a comment to a work item), update (update an existing comment on a work item)."), project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - id: z.coerce.number().min(1).describe("The ID of the work item to remove the links from."), - type: z - .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact"]) - .default("related") - .describe( - "Type of link to remove. Options include 'parent', 'child', 'duplicate', 'duplicate of', 'related', 'successor', 'predecessor', 'tested by', 'tests', 'affects', 'affected by', and 'artifact'. Defaults to 'related'." - ), - url: z.string().optional().describe("Optional URL to match for the link to remove. If not provided, all links of the specified type will be removed."), + workItemId: z.coerce.number().min(1).optional().describe("The ID of the work item. Required for: add, update."), + text: z.string().optional().describe("The comment text. Required for: add, update."), + commentId: z.coerce.number().min(1).optional().describe("The ID of the comment to update. Required for: update."), + format: z.enum(["Markdown", "Html"]).optional().default("Markdown").describe("Format of the comment text. Optional, defaults to 'Markdown'."), }, - async ({ project, id, type, url }) => { + async ({ action, project, workItemId, text, commentId, format }) => { try { const connection = await connectionProvider(); let resolvedProject = project; if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to unlink work items in."); + const label = action === "add" ? "add a work item comment in" : "update the work item comment in"; + const result = await elicitProject(server, connection, `Select the Azure DevOps project to ${label}.`); if ("response" in result) return result.response; resolvedProject = result.resolved; } - const workItemApi = await connection.getWorkItemTrackingApi(); - const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, resolvedProject); - const relations: WorkItemRelation[] = workItem.relations ?? []; - const linkType = getLinkTypeFromName(type); - - let relationIndexes: number[] = []; - - if (url && url.trim().length > 0) { - // If url is provided, find relations matching both rel type and url - relationIndexes = relations.map((relation, idx) => (relation.rel === linkType && relation.url === url ? idx : -1)).filter((idx) => idx !== -1); - } else { - // If url is not provided, find all relations matching rel type - relationIndexes = relations.map((relation, idx) => (relation.rel === linkType ? idx : -1)).filter((idx) => idx !== -1); - } + if (!workItemId) return { content: [{ type: "text", text: "workItemId is required" }], isError: true }; + if (!text) return { content: [{ type: "text", text: "text is required" }], isError: true }; - if (relationIndexes.length === 0) { - return { - content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }], - isError: true, - }; - } + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + const formatParameter = (format ?? "Markdown") === "Markdown" ? 0 : 1; + const resolvedText = await resolveCommentMentions(text, format, tokenProvider, connectionProvider, userAgentProvider); - // Get the relations that will be removed for logging - const removedRelations = relationIndexes.map((idx) => relations[idx]); + if (action === "add") { + const response = await fetch( + `${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, + { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify({ text: resolvedText }), + } + ); - // Sort indexes in descending order to avoid index shifting when removing - relationIndexes.sort((a, b) => b - a); + if (!response.ok) { + throw new Error(`Failed to add a work item comment: ${response.statusText}`); + } - const apiUpdates = relationIndexes.map((idx) => ({ - op: "remove", - path: `/relations/${idx}`, - })); + return { content: [{ type: "text", text: await response.text() }] }; + } - const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, resolvedProject); + if (action === "update") { + if (!commentId) return { content: [{ type: "text", text: "commentId is required for update" }], isError: true }; - return { - content: [ + const response = await fetch( + `${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wit/workItems/${workItemId}/comments/${commentId}?format=${formatParameter}&api-version=${markdownCommentsApiVersion}`, { - type: "text", - text: - `Removed ${removedRelations.length} link(s) of type '${type}':\n` + - JSON.stringify(removedRelations, null, 2) + - `\n\nUpdated work item result:\n` + - JSON.stringify(updatedWorkItem, null, 2), - }, - ], - isError: false, - }; + method: "PATCH", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify({ text: resolvedText }), + } + ); + + if (!response.ok) { + throw new Error(`Failed to update work item comment: ${response.statusText}`); + } + + return { content: [{ type: "text", text: await response.text() }] }; + } + + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { - return { - content: [ - { - type: "text", - text: `Error unlinking work item: ${error instanceof Error ? error.message : "Unknown error occurred"}`, - }, - ], - isError: true, + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + const msgs: Record = { + add: `Error adding work item comment: ${errorMessage}`, + update: `Error updating work item comment: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); + // --- wit_work_item_link_write ----------------------------------------------- server.tool( - WORKITEM_TOOLS.add_artifact_link, - "Add artifact links (repository, branch, commit, builds) to work items. You can either provide the full vstfs URI or the individual components to build it automatically. If a project is not specified, you will be prompted to select one.", + WORKITEM_TOOLS.wit_work_item_link_write, + "Write operations for work item links. Use the action parameter to specify the operation.", { - workItemId: z.coerce.number().min(1).describe("The ID of the work item to add the artifact link to."), - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - - // Option 1: Provide full URI directly - artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact to link. If provided, individual component parameters are ignored."), - - // Option 2: Provide individual components to build URI automatically based on linkType - projectId: z.string().optional().describe("The project ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), - repositoryId: z.string().optional().describe("The repository ID (GUID) containing the artifact. Required for Git artifacts when artifactUri is not provided."), - branchName: z.string().optional().describe("The branch name (e.g., 'main'). Required when linkType is 'Branch'."), - commitId: z.string().optional().describe("The commit SHA hash. Required when linkType is 'Fixed in Commit'."), - pullRequestId: z.coerce.number().min(1).optional().describe("The pull request ID. Required when linkType is 'Pull Request'."), - buildId: z.coerce.number().min(1).optional().describe("The build ID. Required when linkType is 'Build', 'Found in build', or 'Integrated in build'."), - wikiId: z.string().optional().describe("The wiki ID (GUID). Required when linkType is 'Wiki'."), - pageId: z.coerce - .number() - .min(1) - .optional() + action: z + .enum(["link", "unlink", "link_to_pull_request", "add_artifact_link"]) .describe( - "The numeric wiki page ID from the browser URL (e.g., '98' in '.../wikis/Contoso.wiki/98/What-is-Contoso'). When provided for 'Wiki' links, the full page path is resolved automatically via the API. Takes precedence over 'pagePath'." + "The action to perform. Options: link (link two work items together), unlink (remove links from a work item), link_to_pull_request (link a work item to a pull request), add_artifact_link (add a repository, branch, commit, or build artifact link to a work item)." ), - pagePath: z - .string() + project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), + // link + updates: z + .array( + z.object({ + id: z.coerce.number().min(1).describe("The ID of the work item to update."), + linkToId: z.coerce.number().min(1).optional().describe("The ID of the work item to link to. Required unless type is 'hyperlink'."), + url: z.string().optional().describe("The URL for a hyperlink. Required when type is 'hyperlink'."), + type: z + .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "hyperlink"]) + .default("related") + .describe("Type of link. Defaults to 'related'."), + comment: z.string().optional().describe("Optional comment for the link."), + }) + ) .optional() - .describe( - "The full wiki page path from the wiki root (e.g., '/Home/What-is-Contoso'). Required when linkType is 'Wiki' and 'pageId' is not provided. Must be the complete path, not just the page name from the URL." - ), - + .describe("Link operations to apply. Required for: link."), + // unlink + id: z.coerce.number().min(1).optional().describe("Work item ID to remove links from. Required for: unlink."), + type: z + .enum(["parent", "child", "duplicate", "duplicate of", "related", "successor", "predecessor", "tested by", "tests", "affects", "affected by", "artifact", "hyperlink"]) + .optional() + .describe("Link type to remove. Required for: unlink."), + url: z.string().optional().describe("URL to match when removing a link. Used for: unlink. If not provided, all links of the specified type are removed."), + // link_to_pull_request and add_artifact_link + projectId: z.string().optional().describe("The project ID (GUID). Required for: link_to_pull_request, and add_artifact_link (Git/Wiki types)."), + repositoryId: z.string().optional().describe("The repository ID. Required for: link_to_pull_request and add_artifact_link (Git types)."), + pullRequestId: z.coerce.number().min(1).optional().describe("The pull request ID. Required for: link_to_pull_request; used for: add_artifact_link (Pull Request type)."), + workItemId: z.coerce.number().min(1).optional().describe("The work item ID. Required for: link_to_pull_request, add_artifact_link."), + pullRequestProjectId: z.string().optional().describe("Project ID containing the pull request. Used for: link_to_pull_request. Defaults to projectId."), + // add_artifact_link + artifactUri: z.string().optional().describe("The complete VSTFS URI of the artifact. Used for: add_artifact_link. If provided, individual component parameters are ignored."), + branchName: z.string().optional().describe("The branch name. Used for: add_artifact_link (Branch type)."), + commitId: z.string().optional().describe("The commit SHA hash. Used for: add_artifact_link (Fixed in Commit type)."), + buildId: z.coerce.number().min(1).optional().describe("The build ID. Used for: add_artifact_link (Build, Found in build, Integrated in build types)."), + wikiId: z.string().optional().describe("The wiki ID (GUID). Used for: add_artifact_link (Wiki type)."), + pageId: z.coerce.number().min(1).optional().describe("The numeric wiki page ID. Used for: add_artifact_link (Wiki type). Takes precedence over pagePath."), + pagePath: z.string().optional().describe("The full wiki page path. Used for: add_artifact_link (Wiki type) when pageId is not provided."), linkType: z .enum([ "Branch", @@ -1338,304 +954,305 @@ function configureWorkItemTools(server: McpServer, tokenProvider: () => Promise< "Test Result", "Wiki", ]) - .default("Branch") - .describe("Type of artifact link, defaults to 'Branch'. This determines both the link type and how to build the VSTFS URI from individual components."), - comment: z.string().optional().describe("Comment to include with the artifact link."), + .optional() + .describe("Type of artifact link. Used for: add_artifact_link. Defaults to 'Branch'."), + comment: z.string().optional().describe("Comment to include with the artifact link. Used for: add_artifact_link."), }, - async ({ workItemId, project, artifactUri, projectId, repositoryId, branchName, commitId, pullRequestId, buildId, wikiId, pageId, pagePath, linkType, comment }) => { + async ({ + action, + project, + updates, + id, + type, + url, + projectId, + repositoryId, + pullRequestId, + workItemId, + pullRequestProjectId, + artifactUri, + branchName, + commitId, + buildId, + wikiId, + pageId, + pagePath, + linkType, + comment, + }) => { try { const connection = await connectionProvider(); let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to add the artifact link in."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } - const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + if (action === "link") { + if (!updates || updates.length === 0) return { content: [{ type: "text", text: "updates is required for link" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to link work items in."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } - let finalArtifactUri: string; + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + const uniqueIds = Array.from(new Set(updates.map((update) => update.id))); - if (artifactUri) { - // Use the provided full URI - finalArtifactUri = artifactUri; - } else { - // Build the URI from individual components based on linkType - switch (linkType) { - case "Branch": - if (!projectId || !repositoryId || !branchName) { - return { - content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`; - break; + const body = uniqueIds.map((uid) => ({ + method: "PATCH", + uri: `/_apis/wit/workitems/${uid}?api-version=${batchApiVersion}`, + headers: { "Content-Type": "application/json-patch+json" }, + body: updates + .filter((update) => update.id === uid) + .map(({ linkToId, url: linkUrl, type: linkTypeName, comment: linkComment }) => { + if (linkTypeName === "hyperlink" && !linkUrl) { + throw new Error("url is required for hyperlink links"); + } + if (linkTypeName !== "hyperlink" && !linkToId) { + throw new Error("linkToId is required for work item links"); + } - case "Fixed in Commit": - if (!projectId || !repositoryId || !commitId) { return { - content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], - isError: true, + op: "add", + path: "/relations/-", + value: { + rel: getLinkTypeFromName(linkTypeName), + url: linkTypeName === "hyperlink" ? linkUrl : `${orgUrl}/${resolvedProject}/_apis/wit/workItems/${linkToId}`, + attributes: { comment: linkComment || "" }, + }, }; - } - finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`; - break; + }), + })); - case "Pull Request": - if (!projectId || !repositoryId || pullRequestId === undefined) { - return { - content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`; - break; + const response = await fetch(`${orgUrl}/_apis/wit/$batch?api-version=${batchApiVersion}`, { + method: "PATCH", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": userAgentProvider(), + }, + body: JSON.stringify(body), + }); - case "Build": - case "Found in build": - case "Integrated in build": - if (buildId === undefined) { - return { - content: [{ type: "text", text: `For '${linkType}' links, 'buildId' is required.` }], - isError: true, - }; - } - finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`; - break; + if (!response.ok) { + throw new Error(`Failed to update work items in batch: ${response.statusText}`); + } - case "Wiki": { - if (!projectId || !wikiId) { - return { - content: [{ type: "text", text: "For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required." }], - isError: true, - }; - } + const result = await response.json(); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } - let resolvedPagePath = pagePath; + if (action === "unlink") { + if (!id) return { content: [{ type: "text", text: "id is required for unlink" }], isError: true }; + if (!type) return { content: [{ type: "text", text: "type is required for unlink" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to unlink work items in."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } - if (pageId !== undefined) { - // Look up the actual page path by page ID to get the full path - const orgUrl = connection.serverUrl; - const accessToken = await tokenProvider(); - const pageResponse = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages/${pageId}?api-version=7.1`, { - headers: { - "Authorization": `Bearer ${accessToken}`, - "User-Agent": userAgentProvider(), - }, - }); - if (!pageResponse.ok) { - return { - content: [{ type: "text", text: `Failed to look up wiki page ID ${pageId}: ${pageResponse.statusText}` }], - isError: true, - }; - } - const pageData = await pageResponse.json(); - resolvedPagePath = pageData.path as string; - } + const workItemApi = await connection.getWorkItemTrackingApi(); + const workItem = await workItemApi.getWorkItem(id, undefined, undefined, WorkItemExpand.Relations, resolvedProject); + const relations: WorkItemRelation[] = workItem.relations ?? []; + const linkTypeName = getLinkTypeFromName(type); - if (!resolvedPagePath) { - return { - content: [{ type: "text", text: "For 'Wiki' links, 'pageId' or 'pagePath' is required." }], - isError: true, - }; - } + let relationIndexes: number[] = []; - // Strip leading slash, then encode each segment joined by %2F - const normalizedPath = resolvedPagePath.startsWith("/") ? resolvedPagePath.slice(1) : resolvedPagePath; - const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("%2F"); - finalArtifactUri = `vstfs:///Wiki/WikiPage/${encodeURIComponent(projectId)}%2F${encodeURIComponent(wikiId)}%2F${encodedPath}`; - break; - } + if (url && url.trim().length > 0) { + relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName && relation.url === url ? idx : -1)).filter((idx) => idx !== -1); + } else { + relationIndexes = relations.map((relation, idx) => (relation.rel === linkTypeName ? idx : -1)).filter((idx) => idx !== -1); + } - default: - return { - content: [{ type: "text", text: `URI building from components is not supported for link type '${linkType}'. Please provide the full 'artifactUri' instead.` }], - isError: true, - }; + if (relationIndexes.length === 0) { + return { + content: [{ type: "text", text: `No matching relations found for link type '${type}'${url ? ` and URL '${url}'` : ""}.\n${JSON.stringify(relations, null, 2)}` }], + isError: true, + }; } - } - // Create the patch document for adding an artifact link relation - const patchDocument = [ - { - op: "add", - path: "/relations/-", - value: { - rel: "ArtifactLink", - url: finalArtifactUri, - attributes: { - name: getArtifactLinkAttributeName(linkType), - ...(comment && { comment }), - }, - }, - }, - ]; + const removedRelations = relationIndexes.map((idx) => relations[idx]); + relationIndexes.sort((a, b) => b - a); - // Use the WorkItem API to update the work item with the new relation - const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject); + const apiUpdates = relationIndexes.map((idx) => ({ op: "remove", path: `/relations/${idx}` })); + const updatedWorkItem = await workItemApi.updateWorkItem(null, apiUpdates, id, resolvedProject); - if (!workItem) { - return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; + return { + content: [ + { + type: "text", + text: + `Removed ${removedRelations.length} link(s) of type '${type}':\n` + + JSON.stringify(removedRelations, null, 2) + + `\n\nUpdated work item result:\n` + + JSON.stringify(updatedWorkItem, null, 2), + }, + ], + isError: false, + }; } - return { - content: [ + if (action === "link_to_pull_request") { + if (!projectId) return { content: [{ type: "text", text: "projectId is required for link_to_pull_request" }], isError: true }; + if (!repositoryId) return { content: [{ type: "text", text: "repositoryId is required for link_to_pull_request" }], isError: true }; + if (pullRequestId === undefined) return { content: [{ type: "text", text: "pullRequestId is required for link_to_pull_request" }], isError: true }; + if (!workItemId) return { content: [{ type: "text", text: "workItemId is required for link_to_pull_request" }], isError: true }; + + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + const artifactProjectId = pullRequestProjectId && pullRequestProjectId.trim() !== "" ? pullRequestProjectId : projectId; + const artifactPathValue = `${artifactProjectId}/${repositoryId}/${pullRequestId}`; + const vstfsUrl = `vstfs:///Git/PullRequestId/${encodeURIComponent(artifactPathValue)}`; + + const patchDocument = [ { - type: "text", - text: JSON.stringify( - { - workItemId, - artifactUri: finalArtifactUri, - linkType, - comment: comment || null, - success: true, - }, - null, - 2 - ), + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: vstfsUrl, + attributes: { name: "Pull Request" }, + }, }, - ], - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; + ]; - return { - content: [{ type: "text", text: `Error adding artifact link to work item: ${errorMessage}` }], - isError: true, - }; - } - } - ); + const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, projectId); - server.tool( - WORKITEM_TOOLS.query_by_wiql, - "Execute a WIQL (Work Item Query Language) query and return the matching work items. If a project is not specified, you will be prompted to select one.", - { - wiql: z.string().max(32768).describe('The WIQL query string to execute, e.g., "SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = @project"'), - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - team: z.string().optional().describe("The name or ID of the Azure DevOps team. If not provided, the default team context will be used."), - timePrecision: z.boolean().optional().describe("Whether to include time precision in date fields. Defaults to false."), - top: z.coerce.number().default(50).describe("The maximum number of results to return. Defaults to 50."), - }, - async ({ wiql, project, team, timePrecision, top }) => { - try { - const connection = await connectionProvider(); - let resolvedProject = project; + if (!workItem) { + return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; + } - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to run the WIQL query against."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; + return { + content: [{ type: "text", text: JSON.stringify({ workItemId, pullRequestId, success: true }, null, 2) }], + }; } - const workItemApi = await connection.getWorkItemTrackingApi(); - const teamContext = { project: resolvedProject, team }; - const queryResult = await workItemApi.queryByWiql({ query: wiql }, teamContext, timePrecision, top); - - return createExternalContentResponse(queryResult, "wiql query results"); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - - return { - content: [{ type: "text", text: `Error executing WIQL query: ${errorMessage}` }], - isError: true, - }; - } - } - ); - - server.tool( - WORKITEM_TOOLS.get_work_item_attachment, - "Download a work item attachment by its ID. By default returns the content as a base64-encoded resource. If savePath is provided, saves the file locally to that directory and returns the file path instead. Useful for viewing images (e.g. screenshots) or other files attached to work items such as bugs. If a project is not specified, you will be prompted to select one.", - { - project: z.string().optional().describe("The name or ID of the Azure DevOps project. Reuse from prior context if already known. If not provided, a project selection prompt will be shown."), - attachmentId: z.string().describe("The GUID of the attachment. Found in the attachment URL: https://dev.azure.com/{org}/{project}/_apis/wit/attachments/{attachmentId}"), - fileName: z.string().optional().describe("The file name of the attachment, e.g. 'screenshot.png'. Used to determine the MIME type or the saved file's name."), - savePath: z - .string() - .optional() - .describe( - "Optional local directory path where the file should be saved. Must be a relative path (e.g. 'temp' or 'downloads/attachments'); absolute paths and path traversals are not allowed. If provided, saves the attachment to this directory and returns the file path. If omitted, returns the content as a base64-encoded resource." - ), - }, - async ({ project, attachmentId, fileName, savePath }) => { - const isAbsolutePath = (value: string) => path.posix.isAbsolute(value) || path.win32.isAbsolute(value); - const hasDriveLetter = (value: string) => /^[a-zA-Z]:/.test(value); - - if (savePath !== undefined && (savePath.includes("..") || isAbsolutePath(savePath) || hasDriveLetter(savePath))) { - throw new Error("Invalid savePath: absolute paths and path traversals are not allowed."); - } + if (action === "add_artifact_link") { + if (!workItemId) return { content: [{ type: "text", text: "workItemId is required for add_artifact_link" }], isError: true }; + if (!resolvedProject) { + const result = await elicitProject(server, connection, "Select the Azure DevOps project to add the artifact link in."); + if ("response" in result) return result.response; + resolvedProject = result.resolved; + } - if (fileName !== undefined && fileName.includes("..")) { - throw new Error("Invalid fileName: path traversal is not allowed."); - } + const workItemTrackingApi = await connection.getWorkItemTrackingApi(); + const effectiveLinkType = linkType ?? "Branch"; + let finalArtifactUri: string; + + if (artifactUri) { + finalArtifactUri = artifactUri; + } else { + switch (effectiveLinkType) { + case "Branch": + if (!projectId || !repositoryId || !branchName) { + return { content: [{ type: "text", text: "For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required." }], isError: true }; + } + finalArtifactUri = `vstfs:///Git/Ref/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2FGB${encodeURIComponent(branchName)}`; + break; - try { - const connection = await connectionProvider(); + case "Fixed in Commit": + if (!projectId || !repositoryId || !commitId) { + return { content: [{ type: "text", text: "For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required." }], isError: true }; + } + finalArtifactUri = `vstfs:///Git/Commit/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(commitId)}`; + break; - let resolvedProject = project; - if (!resolvedProject) { - const result = await elicitProject(server, connection, "Select the Azure DevOps project to retrieve the work item attachment from."); - if ("response" in result) return result.response; - resolvedProject = result.resolved; - } + case "Pull Request": + if (!projectId || !repositoryId || pullRequestId === undefined) { + return { content: [{ type: "text", text: "For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required." }], isError: true }; + } + finalArtifactUri = `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${encodeURIComponent(pullRequestId.toString())}`; + break; + + case "Build": + case "Found in build": + case "Integrated in build": + if (buildId === undefined) { + return { content: [{ type: "text", text: `For '${effectiveLinkType}' links, 'buildId' is required.` }], isError: true }; + } + finalArtifactUri = `vstfs:///Build/Build/${encodeURIComponent(buildId.toString())}`; + break; - const workItemApi = await connection.getWorkItemTrackingApi(); - const stream = await workItemApi.getAttachmentContent(attachmentId, fileName, resolvedProject); + case "Wiki": { + if (!projectId || !wikiId) { + return { content: [{ type: "text", text: "For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required." }], isError: true }; + } - const chunks: Buffer[] = []; - await new Promise((resolve, reject) => { - stream.on("data", (chunk: Buffer) => chunks.push(Buffer.from(chunk))); - stream.on("end", resolve); - stream.on("error", reject); - }); + let resolvedPagePath = pagePath; + + if (pageId !== undefined) { + const orgUrl = connection.serverUrl; + const accessToken = await tokenProvider(); + const pageResponse = await fetch(`${orgUrl}/${encodeURIComponent(resolvedProject)}/_apis/wiki/wikis/${encodeURIComponent(wikiId)}/pages/${pageId}?api-version=7.1`, { + headers: { + "Authorization": `Bearer ${accessToken}`, + "User-Agent": userAgentProvider(), + }, + }); + if (!pageResponse.ok) { + return { content: [{ type: "text", text: `Failed to look up wiki page ID ${pageId}: ${pageResponse.statusText}` }], isError: true }; + } + const pageData = await pageResponse.json(); + resolvedPagePath = pageData.path as string; + } - const buffer = Buffer.concat(chunks); + if (!resolvedPagePath) { + return { content: [{ type: "text", text: "For 'Wiki' links, 'pageId' or 'pagePath' is required." }], isError: true }; + } - if (savePath) { - const resolvedFileName = fileName ?? attachmentId; - const localFilePath = path.join(savePath, resolvedFileName); + const normalizedPath = resolvedPagePath.startsWith("/") ? resolvedPagePath.slice(1) : resolvedPagePath; + const encodedPath = normalizedPath.split("/").map(encodeURIComponent).join("%2F"); + finalArtifactUri = `vstfs:///Wiki/WikiPage/${encodeURIComponent(projectId)}%2F${encodeURIComponent(wikiId)}%2F${encodedPath}`; + break; + } - if (fs.existsSync(localFilePath)) { - throw new Error(`File already exists: ${localFilePath}`); + default: + return { + content: [{ type: "text", text: `URI building from components is not supported for link type '${effectiveLinkType}'. Please provide the full 'artifactUri' instead.` }], + isError: true, + }; + } } - fs.writeFileSync(localFilePath, buffer); + const patchDocument = [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: finalArtifactUri, + attributes: { + name: getArtifactLinkAttributeName(effectiveLinkType), + ...(comment && { comment }), + }, + }, + }, + ]; - return { - content: [{ type: "text", text: `Attachment saved to: ${localFilePath}` }], - }; - } + const workItem = await workItemTrackingApi.updateWorkItem({}, patchDocument, workItemId, resolvedProject); - const mimeType = getMimeType(fileName); + if (!workItem) { + return { content: [{ type: "text", text: "Work item update failed" }], isError: true }; + } - if (mimeType.startsWith("text/")) { return { - content: [{ type: "text", text: buffer.toString("utf-8") }], + content: [ + { + type: "text", + text: JSON.stringify({ workItemId, artifactUri: finalArtifactUri, linkType: effectiveLinkType, comment: comment || null, success: true }, null, 2), + }, + ], }; } - const base64Data = buffer.toString("base64"); - return { - content: [ - { - type: "resource", - resource: { - uri: `data:${mimeType};base64,${base64Data}`, - mimeType, - blob: base64Data, - }, - }, - ], - }; + return { content: [{ type: "text", text: `Unknown action: ${action}` }], isError: true }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; - return { - content: [{ type: "text", text: `Error retrieving work item attachment: ${errorMessage}` }], - isError: true, + const msgs: Record = { + link: `Error linking work items: ${errorMessage}`, + unlink: `Error unlinking work item: ${errorMessage}`, + link_to_pull_request: `Error linking work item to pull request: ${errorMessage}`, + add_artifact_link: `Error adding artifact link to work item: ${errorMessage}`, }; + return { content: [{ type: "text", text: msgs[action] ?? `Error: ${errorMessage}` }], isError: true }; } } ); diff --git a/src/tools/work.ts b/src/tools/work.ts index 5ef9d943..d0758015 100644 --- a/src/tools/work.ts +++ b/src/tools/work.ts @@ -76,7 +76,6 @@ function configureWorkTools(server: McpServer, _: () => Promise, connect const workItemTrackingApi = await connection.getWorkItemTrackingApi(); const effectiveDepth = depth ?? 1; - const results = await workItemTrackingApi.getClassificationNodes(resolvedProject, [], effectiveDepth); if (!results) { diff --git a/src/version.ts b/src/version.ts index 7bf2dc8b..2705e5a1 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const packageVersion = "2.8.1"; +export const packageVersion = "2.9.0"; diff --git a/test/src/pat-auth.test.ts b/test/src/pat-auth.test.ts index 3bd5deab..e2ae814e 100644 --- a/test/src/pat-auth.test.ts +++ b/test/src/pat-auth.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it, beforeEach, afterEach } from "@jest/globals"; import { jest } from "@jest/globals"; +import { AzureCliCredential, ChainedTokenCredential, DefaultAzureCredential } from "@azure/identity"; +import { PublicClientApplication } from "@azure/msal-node"; +import open from "open"; jest.mock("../../src/logger.js", () => ({ logger: { @@ -32,6 +35,11 @@ describe("PAT authentication", () => { beforeEach(() => { process.env = { ...originalEnv }; + (AzureCliCredential as unknown as jest.Mock).mockReset(); + (ChainedTokenCredential as unknown as jest.Mock).mockReset(); + (DefaultAzureCredential as unknown as jest.Mock).mockReset(); + (PublicClientApplication as unknown as jest.Mock).mockReset(); + (open as jest.Mock).mockReset(); }); afterEach(() => { @@ -81,6 +89,109 @@ describe("PAT authentication", () => { }); }); + describe("createAuthenticator('envvar')", () => { + it("should return ADO_MCP_AUTH_TOKEN", async () => { + process.env["ADO_MCP_AUTH_TOKEN"] = "environment-token"; + + await expect(createAuthenticator("envvar")()).resolves.toBe("environment-token"); + }); + + it("should throw when ADO_MCP_AUTH_TOKEN is not set", async () => { + delete process.env["ADO_MCP_AUTH_TOKEN"]; + + await expect(createAuthenticator("envvar")()).rejects.toThrow("Environment variable 'ADO_MCP_AUTH_TOKEN' is not set or empty"); + }); + }); + + describe("Azure credential authentication", () => { + it("should use DefaultAzureCredential for env authentication", async () => { + const getToken = jest.fn().mockResolvedValue({ token: "default-token" }); + (DefaultAzureCredential as unknown as jest.Mock).mockImplementation(() => ({ getToken })); + delete process.env.AZURE_TOKEN_CREDENTIALS; + + await expect(createAuthenticator("env")()).resolves.toBe("default-token"); + + expect(process.env.AZURE_TOKEN_CREDENTIALS).toBeUndefined(); + expect(getToken).toHaveBeenCalledWith(["499b84ac-1321-427f-aa17-267ca6975798/.default"]); + }); + + it("should use a tenant-specific Azure CLI credential chain for azcli authentication", async () => { + const defaultCredential = { getToken: jest.fn() }; + const azureCliCredential = { getToken: jest.fn() }; + const getToken = jest.fn().mockResolvedValue({ token: "chained-token" }); + (DefaultAzureCredential as unknown as jest.Mock).mockImplementation(() => defaultCredential); + (AzureCliCredential as unknown as jest.Mock).mockImplementation(() => azureCliCredential); + (ChainedTokenCredential as unknown as jest.Mock).mockImplementation(() => ({ getToken })); + + await expect(createAuthenticator("azcli", "tenant-id")()).resolves.toBe("chained-token"); + + expect(process.env.AZURE_TOKEN_CREDENTIALS).toBe("dev"); + expect(AzureCliCredential).toHaveBeenCalledWith({ tenantId: "tenant-id" }); + expect(ChainedTokenCredential).toHaveBeenCalledWith(azureCliCredential, defaultCredential); + }); + + it("should throw when the Azure credential returns no token", async () => { + (DefaultAzureCredential as unknown as jest.Mock).mockImplementation(() => ({ getToken: jest.fn().mockResolvedValue(null) })); + + await expect(createAuthenticator("env")()).rejects.toThrow("Failed to obtain Azure DevOps token"); + }); + }); + + describe("OAuth authentication", () => { + it("should use tenant-specific interactive authentication, open the browser, and cache the account", async () => { + const account = { homeAccountId: "account-id" }; + const acquireTokenSilent = jest.fn().mockResolvedValue({ accessToken: "silent-token", account }); + const acquireTokenInteractive = jest.fn().mockImplementation(async ({ openBrowser }) => { + await openBrowser("https://login.example.com"); + return { accessToken: "interactive-token", account }; + }); + (PublicClientApplication as unknown as jest.Mock).mockImplementation(() => ({ acquireTokenSilent, acquireTokenInteractive })); + + const authenticator = createAuthenticator("oauth", "tenant-id"); + + await expect(authenticator()).resolves.toBe("interactive-token"); + await expect(authenticator()).resolves.toBe("silent-token"); + expect(PublicClientApplication).toHaveBeenCalledWith({ + auth: { + clientId: "0d50963b-7bb9-4fe7-94c7-a99af00b5136", + authority: "https://login.microsoftonline.com/tenant-id", + }, + }); + expect(open).toHaveBeenCalledWith("https://login.example.com"); + expect(acquireTokenSilent).toHaveBeenCalledWith(expect.objectContaining({ account })); + expect(acquireTokenInteractive).toHaveBeenCalledTimes(1); + }); + + it.each([new Error("silent failure"), "silent failure"])("should fall back to interactive authentication when silent acquisition rejects with %p", async (error) => { + const account = { homeAccountId: "account-id" }; + const acquireTokenSilent = jest.fn().mockRejectedValue(error); + const acquireTokenInteractive = jest.fn().mockResolvedValueOnce({ accessToken: "first-token", account }).mockResolvedValueOnce({ accessToken: "fallback-token", account }); + (PublicClientApplication as unknown as jest.Mock).mockImplementation(() => ({ acquireTokenSilent, acquireTokenInteractive })); + + const authenticator = createAuthenticator("oauth"); + + await expect(authenticator()).resolves.toBe("first-token"); + await expect(authenticator()).resolves.toBe("fallback-token"); + expect(acquireTokenInteractive).toHaveBeenCalledTimes(2); + }); + + it("should use the common authority for the zero tenant ID", async () => { + const acquireTokenInteractive = jest.fn().mockResolvedValue({ accessToken: "token", account: null }); + (PublicClientApplication as unknown as jest.Mock).mockImplementation(() => ({ acquireTokenInteractive })); + + await expect(createAuthenticator("oauth", "00000000-0000-0000-0000-000000000000")()).resolves.toBe("token"); + + expect(PublicClientApplication).toHaveBeenCalledWith(expect.objectContaining({ auth: expect.objectContaining({ authority: "https://login.microsoftonline.com/common" }) })); + }); + + it("should throw when interactive authentication returns no access token", async () => { + const acquireTokenInteractive = jest.fn().mockResolvedValue({ accessToken: "", account: null }); + (PublicClientApplication as unknown as jest.Mock).mockImplementation(() => ({ acquireTokenInteractive })); + + await expect(createAuthenticator("oauth")()).rejects.toThrow("Failed to obtain Azure DevOps OAuth token"); + }); + }); + describe("PAT token extraction for WebApi handler", () => { it("should correctly extract raw PAT from base64(email:pat)", () => { const email = "user@example.com"; diff --git a/test/src/tools/pipelines.test.ts b/test/src/tools/pipelines.test.ts index ed61f66c..e0d8f32e 100644 --- a/test/src/tools/pipelines.test.ts +++ b/test/src/tools/pipelines.test.ts @@ -5,7 +5,8 @@ import { describe, expect, it, beforeEach } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; import { StageUpdateType } from "azure-devops-node-api/interfaces/BuildInterfaces.js"; -import { configurePipelineTools } from "../../../src/tools/pipelines"; +import { configurePipelineTools, runPipelineCommand, createPipelineCommand, updateBuildStageCommand } from "../../../src/tools/pipelines"; +import { CommandContext } from "../../../src/shared/command"; import { apiVersion } from "../../../src/utils.js"; import { mockUpdateBuildStageResponse, mockMultipleArtifacts, mockArtifact } from "../../mocks/pipelines"; import { Readable } from "stream"; @@ -1600,10 +1601,10 @@ describe("configurePipelineTools", () => { }); it.each([ - ["name", { yamlPath: "p.yml", repositoryType: "AzureReposGit" as const, repositoryName: "repo" }, "name is required for create_pipeline"], - ["yamlPath", { name: "pipe", repositoryType: "AzureReposGit" as const, repositoryName: "repo" }, "yamlPath is required for create_pipeline"], + ["name", { yamlPath: "p.yml", repositoryType: "AzureReposGit", repositoryName: "repo" }, "name is required for create_pipeline"], + ["yamlPath", { name: "pipe", repositoryType: "AzureReposGit", repositoryName: "repo" }, "yamlPath is required for create_pipeline"], ["repositoryType", { name: "pipe", yamlPath: "p.yml", repositoryName: "repo" }, "repositoryType is required for create_pipeline"], - ["repositoryName", { name: "pipe", yamlPath: "p.yml", repositoryType: "AzureReposGit" as const }, "repositoryName is required for create_pipeline"], + ["repositoryName", { name: "pipe", yamlPath: "p.yml", repositoryType: "AzureReposGit" }, "repositoryName is required for create_pipeline"], ])("should return error when %s is missing for create_pipeline", async (_field, extra, expectedMsg) => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_write"); @@ -1850,20 +1851,21 @@ describe("configurePipelineTools", () => { expect(result.isError).toBe(true); expect(result.content[0].text).toBe(expectedMsg); + expect(connectionProvider).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); }); - it("should use generic error message when action is unknown and connectionProvider throws", async () => { + it("should return an unknown action message without opening a connection for an unknown action", async () => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_write"); if (!call) fail("Tool not found"); const [, , , handler] = call; - (connectionProvider as jest.Mock).mockRejectedValueOnce(new Error("connection failed")); - const result = await handler({ action: "unknown" as any, project: "test-project" }); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe("Error: connection failed"); + expect(result.content[0].text).toBe("Unknown action: unknown. Supported actions: create_pipeline, run_pipeline, update_build_stage"); + expect(connectionProvider as jest.Mock).not.toHaveBeenCalled(); }); it("should handle non-Error thrown values in pipelines_write", async () => { @@ -2241,3 +2243,253 @@ describe("configurePipelineTools", () => { }); }); }); + +// Direct, isolated unit tests for each pipelines_write command. These exercise +// the Command implementations against a mock CommandContext, independent of the +// tool dispatcher. +describe("pipelines_write commands", () => { + let tokenProvider: jest.Mock; + let userAgentProvider: () => string; + let mockConnection: { getPipelinesApi: jest.Mock; serverUrl: string }; + let connectionProvider: jest.Mock; + let context: CommandContext; + + beforeEach(() => { + tokenProvider = jest.fn(); + userAgentProvider = () => "Jest"; + mockConnection = { + getPipelinesApi: jest.fn(), + serverUrl: "https://dev.azure.com/test-org", + }; + connectionProvider = jest.fn().mockResolvedValue(mockConnection); + context = { + connectionProvider: connectionProvider as unknown as CommandContext["connectionProvider"], + tokenProvider: tokenProvider as unknown as CommandContext["tokenProvider"], + userAgentProvider, + }; + (global.fetch as jest.MockedFunction).mockClear(); + }); + + describe("runPipelineCommand", () => { + it("runs a pipeline with the given resources and parameters", async () => { + const runPipeline = jest.fn().mockResolvedValue({ id: 456 }); + mockConnection.getPipelinesApi.mockResolvedValue({ runPipeline }); + + const result = await runPipelineCommand.execute(context, { + project: "test-project", + pipelineId: 123, + resources: { repositories: { self: { refName: "refs/heads/main" } } }, + templateParameters: { key1: "value1" }, + }); + + expect(runPipeline).toHaveBeenCalledWith( + { + previewRun: undefined, + resources: { repositories: { self: { refName: "refs/heads/main" } } }, + stagesToSkip: undefined, + templateParameters: { key1: "value1" }, + variables: undefined, + yamlOverride: undefined, + }, + "test-project", + 123, + undefined + ); + expect(result.content[0].text).toBe(JSON.stringify({ id: 456 }, null, 2)); + expect(result.isError).toBeUndefined(); + }); + + it("returns an error when pipelineId is missing", async () => { + const result = await runPipelineCommand.execute(context, { project: "test-project" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("pipelineId is required for run_pipeline"); + expect(connectionProvider).not.toHaveBeenCalled(); + }); + + it("throws when yamlOverride is provided without previewRun", async () => { + await expect( + runPipelineCommand.execute(context, { + project: "test-project", + pipelineId: 123, + previewRun: false, + yamlOverride: "some yaml", + }) + ).rejects.toThrow("Parameter 'yamlOverride' can only be specified together with parameter 'previewRun'."); + }); + + it("throws when the pipeline run has no id", async () => { + const runPipeline = jest.fn().mockResolvedValue({}); + mockConnection.getPipelinesApi.mockResolvedValue({ runPipeline }); + + await expect(runPipelineCommand.execute(context, { project: "test-project", pipelineId: 123 })).rejects.toThrow("Failed to get build ID from pipeline run"); + }); + }); + + describe("createPipelineCommand", () => { + it("creates a YAML pipeline for AzureReposGit", async () => { + const createPipeline = jest.fn().mockResolvedValue({ id: 100, name: "Pipeline" }); + mockConnection.getPipelinesApi.mockResolvedValue({ createPipeline }); + + const result = await createPipelineCommand.execute(context, { + project: "ProjectName", + name: "Pipeline", + yamlPath: "pipeline.yml", + repositoryType: "AzureReposGit", + repositoryName: "RepositoryName", + repositoryId: "46DEE968-EAE5-41AA-97B1-E8B71DC287C2", + }); + + expect(createPipeline).toHaveBeenCalledWith( + { + name: "Pipeline", + folder: "\\", + configuration: { + type: "Yaml", + path: "pipeline.yml", + repository: { + type: "AzureReposGit", + name: "RepositoryName", + id: "46DEE968-EAE5-41AA-97B1-E8B71DC287C2", + }, + variables: undefined, + }, + }, + "ProjectName" + ); + expect(result.content[0].text).toBe(JSON.stringify({ id: 100, name: "Pipeline" }, null, 2)); + }); + + it("creates a YAML pipeline for GitHub", async () => { + const createPipeline = jest.fn().mockResolvedValue({ id: 200, name: "GH Pipeline" }); + mockConnection.getPipelinesApi.mockResolvedValue({ createPipeline }); + + await createPipelineCommand.execute(context, { + project: "ProjectName", + name: "GH Pipeline", + yamlPath: "pipeline.yml", + repositoryType: "GitHub", + repositoryName: "owner/repo", + repositoryConnectionId: "conn-id-123", + }); + + expect(createPipeline).toHaveBeenCalledWith( + expect.objectContaining({ + configuration: expect.objectContaining({ + repository: expect.objectContaining({ + type: "GitHub", + fullname: "owner/repo", + connection: { id: "conn-id-123" }, + }), + }), + }), + "ProjectName" + ); + }); + + it("throws when repositoryConnectionId is missing for GitHub", async () => { + mockConnection.getPipelinesApi.mockResolvedValue({ createPipeline: jest.fn() }); + + await expect( + createPipelineCommand.execute(context, { + project: "ProjectName", + name: "GH Pipeline", + yamlPath: "pipeline.yml", + repositoryType: "GitHub", + repositoryName: "owner/repo", + }) + ).rejects.toThrow("Parameter 'repositoryConnectionId' is required for GitHub repositories."); + }); + + it("throws for an unsupported repository type", async () => { + mockConnection.getPipelinesApi.mockResolvedValue({ createPipeline: jest.fn() }); + + await expect( + createPipelineCommand.execute(context, { + project: "ProjectName", + name: "Pipeline", + yamlPath: "pipeline.yml", + repositoryType: "BitbucketCloud", + repositoryName: "owner/repo", + }) + ).rejects.toThrow("Unsupported repository type"); + }); + + it.each([ + ["name", { yamlPath: "p.yml", repositoryType: "AzureReposGit", repositoryName: "repo" }, "name is required for create_pipeline"], + ["yamlPath", { name: "pipe", repositoryType: "AzureReposGit", repositoryName: "repo" }, "yamlPath is required for create_pipeline"], + ["repositoryType", { name: "pipe", yamlPath: "p.yml", repositoryName: "repo" }, "repositoryType is required for create_pipeline"], + ["repositoryName", { name: "pipe", yamlPath: "p.yml", repositoryType: "AzureReposGit" }, "repositoryName is required for create_pipeline"], + ])("returns an error when %s is missing", async (_field, extra, expectedMsg) => { + const result = await createPipelineCommand.execute(context, { project: "proj", ...(extra as Record) }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe(expectedMsg); + expect(connectionProvider).not.toHaveBeenCalled(); + }); + }); + + describe("updateBuildStageCommand", () => { + it("sends a PATCH request with encoded path segments and returns the response", async () => { + tokenProvider.mockResolvedValue("mock-token"); + const mockResponse = { + ok: true, + text: jest.fn().mockResolvedValue(JSON.stringify(mockUpdateBuildStageResponse)), + }; + (global.fetch as jest.MockedFunction).mockResolvedValue(mockResponse as unknown as Response); + + const result = await updateBuildStageCommand.execute(context, { + project: "test-project", + buildId: 123, + stageName: "Build", + status: "Retry", + forceRetryAllJobs: true, + }); + + expect(global.fetch).toHaveBeenCalledWith(`https://dev.azure.com/test-org/test-project/_apis/build/builds/123/stages/Build?api-version=${apiVersion}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer mock-token", + "User-Agent": "Jest", + }, + body: JSON.stringify({ forceRetryAllJobs: true, state: StageUpdateType.Retry.valueOf() }), + }); + expect(result.content[0].text).toBe(JSON.stringify(JSON.stringify(mockUpdateBuildStageResponse), null, 2)); + expect(result.isError).toBeUndefined(); + }); + + it("throws when the response is not ok", async () => { + tokenProvider.mockResolvedValue("mock-token"); + const mockResponse = { + ok: false, + status: 404, + text: jest.fn().mockResolvedValue("Build stage not found"), + }; + (global.fetch as jest.MockedFunction).mockResolvedValue(mockResponse as unknown as Response); + + await expect( + updateBuildStageCommand.execute(context, { + project: "test-project", + buildId: 999, + stageName: "Build", + status: "Retry", + forceRetryAllJobs: false, + }) + ).rejects.toThrow("Failed to update build stage: 404 Build stage not found"); + }); + + it.each([ + ["buildId", { stageName: "Build", status: "Retry", forceRetryAllJobs: false }, "buildId is required for update_build_stage"], + ["stageName", { buildId: 1, status: "Retry", forceRetryAllJobs: false }, "stageName is required for update_build_stage"], + ["status", { buildId: 1, stageName: "Build", forceRetryAllJobs: false }, "status is required for update_build_stage"], + ])("returns an error when %s is missing", async (_field, extra, expectedMsg) => { + const result = await updateBuildStageCommand.execute(context, { project: "test-project", ...(extra as Record) } as Parameters[1]); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe(expectedMsg); + expect(connectionProvider).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts index bdb32e65..d3b69a0b 100644 --- a/test/src/tools/repositories.test.ts +++ b/test/src/tools/repositories.test.ts @@ -104,9 +104,9 @@ describe("repos tools", () => { it("should update pull request with all provided fields", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -129,6 +129,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -175,9 +176,9 @@ describe("repos tools", () => { it("should update pull request with only title", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -199,6 +200,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -239,9 +241,9 @@ describe("repos tools", () => { it("should update pull request status to Active", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -263,6 +265,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -303,9 +306,9 @@ describe("repos tools", () => { it("should update pull request status to Abandoned", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -327,6 +330,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -367,9 +371,9 @@ describe("repos tools", () => { it("should update pull request with status and other fields", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -391,6 +395,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -433,12 +438,13 @@ describe("repos tools", () => { it("should return error when no fields provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -454,8 +460,8 @@ describe("repos tools", () => { it("should update pull request with autocomplete enabled", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -477,6 +483,7 @@ describe("repos tools", () => { }); const params = { + action: "update", repositoryId: "test-repo-id", pullRequestId: 123, project: "test-project", @@ -510,8 +517,8 @@ describe("repos tools", () => { it("should set merge commit message when autocomplete is enabled", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -534,6 +541,7 @@ describe("repos tools", () => { }); const params = { + action: "update", repositoryId: "test-repo-id", pullRequestId: 123, project: "test-project", @@ -567,8 +575,8 @@ describe("repos tools", () => { it("should disable autocomplete when autoComplete is false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -581,6 +589,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "test-repo-id", pullRequestId: 123, project: "test-project", @@ -604,8 +613,8 @@ describe("repos tools", () => { it("should not bypass policies when bypassReason is not provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -627,6 +636,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "test-repo-id", pullRequestId: 123, project: "test-project", @@ -652,8 +662,8 @@ describe("repos tools", () => { it("should automatically bypass policies when bypassReason is provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockUpdatedPR = { @@ -675,6 +685,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "test-repo-id", pullRequestId: 123, project: "test-project", @@ -702,8 +713,8 @@ describe("repos tools", () => { it("should handle description over 4000 characters", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const longDescription = "a".repeat(4001); @@ -715,6 +726,7 @@ describe("repos tools", () => { }); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -729,8 +741,8 @@ describe("repos tools", () => { it("should update pull request labels by replacing existing labels", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; // Mock existing labels @@ -766,6 +778,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -796,8 +809,8 @@ describe("repos tools", () => { it("should update pull request with labels and other fields", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; // Mock existing labels @@ -826,6 +839,7 @@ describe("repos tools", () => { mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -861,8 +875,8 @@ describe("repos tools", () => { it("should update pull request labels to empty array (remove all labels)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; // Mock existing labels @@ -892,6 +906,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -920,8 +935,8 @@ describe("repos tools", () => { it("should handle labels when existing PR has no labels", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); - if (!call) throw new Error("repo_update_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; // Mock no existing labels @@ -947,6 +962,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockUpdatedPR); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 123, project: "test-project", @@ -973,8 +989,8 @@ describe("repos tools", () => { it("should create pull request with basic fields", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockCreatedPR = { @@ -996,6 +1012,7 @@ describe("repos tools", () => { mockGitApi.createPullRequest.mockResolvedValue(mockCreatedPR); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1044,8 +1061,8 @@ describe("repos tools", () => { it("should create pull request with all optional fields including labels", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockCreatedPR = { @@ -1068,6 +1085,7 @@ describe("repos tools", () => { mockGitApi.createPullRequest.mockResolvedValue(mockCreatedPR); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1125,13 +1143,14 @@ describe("repos tools", () => { it("should reject pull request with description over 4000 characters", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const longDescription = "a".repeat(4001); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1147,8 +1166,8 @@ describe("repos tools", () => { it("should fall back to getPullRequests when createPullRequest returns null", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockFallbackPR = { @@ -1173,6 +1192,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue([mockFallbackPR]); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1214,14 +1234,15 @@ describe("repos tools", () => { it("should return error when createPullRequest returns null and fallback finds no PRs", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.createPullRequest.mockResolvedValue(null); mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1231,7 +1252,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result.content[0].text).toContain('repositoryId="repo123"'); - expect(result.content[0].text).toContain("repo_list_repos_by_project"); + expect(result.content[0].text).toContain("repo_repository"); expect(result.isError).toBe(true); }); }); @@ -1240,7 +1261,7 @@ describe("repos tools", () => { it("should create branch with default source branch (main)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1288,7 +1309,7 @@ describe("repos tools", () => { it("should create branch with custom source branch", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1336,7 +1357,7 @@ describe("repos tools", () => { it("should create branch with specific commit ID", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1379,7 +1400,7 @@ describe("repos tools", () => { it("should handle source branch not found error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1401,7 +1422,7 @@ describe("repos tools", () => { it("should handle getRefs API error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1424,7 +1445,7 @@ describe("repos tools", () => { it("should handle updateRefs failure", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1460,7 +1481,7 @@ describe("repos tools", () => { it("should handle updateRefs failure without custom message", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1495,7 +1516,7 @@ describe("repos tools", () => { it("should handle updateRefs API error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1526,7 +1547,7 @@ describe("repos tools", () => { it("should handle source branch with missing objectId", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); if (!call) throw new Error("repo_create_branch tool not registered"); const [, , , handler] = call; @@ -1557,19 +1578,20 @@ describe("repos tools", () => { it("should add reviewers to pull request", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); - if (!call) throw new Error("repo_update_pull_request_reviewers tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockReviewers = [{ id: "reviewer1" }, { id: "reviewer2" }]; mockGitApi.createPullRequestReviewers.mockResolvedValue(mockReviewers); const params = { + action: "update_reviewers", repositoryId: "repo123", pullRequestId: 456, project: "test-project", reviewerIds: ["reviewer1", "reviewer2"], - action: "add" as const, + reviewerAction: "add" as const, }; const result = await handler(params); @@ -1582,18 +1604,19 @@ describe("repos tools", () => { it("should remove reviewers from pull request", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); - if (!call) throw new Error("repo_update_pull_request_reviewers tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.deletePullRequestReviewer.mockResolvedValue({}); const params = { + action: "update_reviewers", repositoryId: "repo123", pullRequestId: 456, project: "test-project", reviewerIds: ["reviewer1", "reviewer2"], - action: "remove" as const, + reviewerAction: "remove" as const, }; const result = await handler(params); @@ -1610,8 +1633,8 @@ describe("repos tools", () => { it("should list repositories by project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); - if (!call) throw new Error("repo_list_repos_by_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -1637,6 +1660,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "list", project: "test-project", status: "Active", top: 100, @@ -1663,8 +1687,8 @@ describe("repos tools", () => { it("should filter repositories by name", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); - if (!call) throw new Error("repo_list_repos_by_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -1675,6 +1699,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "list", project: "test-project", repoNameFilter: "frontend", top: 100, @@ -1693,8 +1718,8 @@ describe("repos tools", () => { it("should list pull requests by repository", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -1714,6 +1739,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue(mockPRs); const params = { + action: "list", repositoryId: "repo123", top: 100, skip: 0, @@ -1732,13 +1758,14 @@ describe("repos tools", () => { it("should filter pull requests created by me", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", created_by_me: true, status: "Active", @@ -1755,13 +1782,14 @@ describe("repos tools", () => { it("should filter pull requests where I am a reviewer", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", i_am_reviewer: true, status: "Active", @@ -1778,13 +1806,14 @@ describe("repos tools", () => { it("should filter pull requests created by me and where I am a reviewer", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", created_by_me: true, i_am_reviewer: true, @@ -1809,8 +1838,8 @@ describe("repos tools", () => { it("should filter pull requests created by specific user successfully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock successful user lookup @@ -1818,6 +1847,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", created_by_user: "john@example.com", status: "Active", @@ -1834,13 +1864,14 @@ describe("repos tools", () => { it("should filter pull requests by source branch", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", status: "Active", @@ -1867,13 +1898,14 @@ describe("repos tools", () => { it("should filter pull requests by target branch", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", targetRefName: "refs/heads/main", status: "Active", @@ -1900,13 +1932,14 @@ describe("repos tools", () => { it("should filter pull requests by both source and target branches", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1935,13 +1968,14 @@ describe("repos tools", () => { it("should combine branch filters with user filters", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -1975,8 +2009,8 @@ describe("repos tools", () => { it("should filter pull requests by specific reviewer successfully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock successful user lookup @@ -1984,6 +2018,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", user_is_reviewer: "reviewer@example.com", status: "Active", @@ -2000,8 +2035,8 @@ describe("repos tools", () => { it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock successful user lookup @@ -2009,6 +2044,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", user_is_reviewer: "specific-reviewer@example.com", i_am_reviewer: true, // This should be ignored @@ -2034,14 +2070,15 @@ describe("repos tools", () => { it("should handle error when user_is_reviewer user not found", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock user lookup failure mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", repositoryId: "repo123", user_is_reviewer: "nonexistent@example.com", status: "Active", @@ -2062,8 +2099,8 @@ describe("repos tools", () => { it("should list pull requests by project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2084,6 +2121,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", status: "Active", top: 100, @@ -2116,8 +2154,8 @@ describe("repos tools", () => { it("should map statusName from PullRequestStatus enum values", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2149,6 +2187,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", status: "Active", top: 100, @@ -2192,8 +2231,8 @@ describe("repos tools", () => { it("should return Unknown statusName for unrecognized pull request status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2213,6 +2252,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs as any); const params = { + action: "list", project: "test-project", status: "Active", top: 100, @@ -2243,8 +2283,8 @@ describe("repos tools", () => { it("should filter by current user when created_by_me is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2265,6 +2305,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", created_by_me: true, status: "Active", @@ -2299,8 +2340,8 @@ describe("repos tools", () => { it("should filter by current user as reviewer when i_am_reviewer is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2321,6 +2362,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", i_am_reviewer: true, status: "Active", @@ -2355,8 +2397,8 @@ describe("repos tools", () => { it("should filter by both creator and reviewer when both created_by_me and i_am_reviewer are true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPRs = [ @@ -2377,6 +2419,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", created_by_me: true, i_am_reviewer: true, @@ -2412,8 +2455,8 @@ describe("repos tools", () => { it("should prioritize created_by_user over created_by_me flag", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock getUserIdFromEmail to return a specific user ID @@ -2437,6 +2480,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", created_by_user: "specific@example.com", created_by_me: true, // This should be ignored since created_by_user takes precedence @@ -2473,13 +2517,14 @@ describe("repos tools", () => { it("should filter pull requests by source branch", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", sourceRefName: "refs/heads/feature-branch", status: "Active", @@ -2504,13 +2549,14 @@ describe("repos tools", () => { it("should filter pull requests by target branch", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", targetRefName: "refs/heads/main", status: "Active", @@ -2535,13 +2581,14 @@ describe("repos tools", () => { it("should filter pull requests by both source and target branches", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -2568,13 +2615,14 @@ describe("repos tools", () => { it("should combine branch filters with user filters", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", @@ -2604,8 +2652,8 @@ describe("repos tools", () => { it("should filter pull requests by specific reviewer successfully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock successful user lookup @@ -2628,6 +2676,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue(mockPRs); const params = { + action: "list", project: "test-project", user_is_reviewer: "reviewer@example.com", status: "Active", @@ -2662,8 +2711,8 @@ describe("repos tools", () => { it("should prioritize user_is_reviewer over i_am_reviewer flag", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock successful user lookup @@ -2671,6 +2720,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", user_is_reviewer: "specific-reviewer@example.com", i_am_reviewer: true, // This should be ignored @@ -2689,14 +2739,15 @@ describe("repos tools", () => { it("should handle error when user_is_reviewer user not found", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock user lookup failure mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", project: "test-project", user_is_reviewer: "nonexistent@example.com", status: "Active", @@ -2715,8 +2766,8 @@ describe("repos tools", () => { it("should support both created_by_user and user_is_reviewer filters simultaneously", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock both user lookups @@ -2727,6 +2778,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", created_by_user: "creator@example.com", user_is_reviewer: "reviewer@example.com", @@ -2757,8 +2809,8 @@ describe("repos tools", () => { it("should list pull request threads", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -2783,6 +2835,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, top: 100, @@ -2818,14 +2871,15 @@ describe("repos tools", () => { it("should return full response when requested", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [{ id: 1, fullData: "complete" }]; mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, fullResponse: true, @@ -2841,13 +2895,14 @@ describe("repos tools", () => { it("should return an empty array when no pull request threads are returned", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; mockGitApi.getThreads.mockResolvedValue(undefined); const result = await handler({ + action: "list", repositoryId: "repo123", pullRequestId: 456, top: 100, @@ -2862,13 +2917,14 @@ describe("repos tools", () => { it("should return an empty full response when no pull request threads are returned", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; mockGitApi.getThreads.mockResolvedValue(undefined); const result = await handler({ + action: "list", repositoryId: "repo123", pullRequestId: 456, fullResponse: true, @@ -2884,8 +2940,8 @@ describe("repos tools", () => { it("should filter threads by status (Active)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -2908,6 +2964,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Active", @@ -2926,8 +2983,8 @@ describe("repos tools", () => { it("should filter threads by status (Closed)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -2945,6 +3002,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Closed", @@ -2963,8 +3021,8 @@ describe("repos tools", () => { it("should filter threads by status (Fixed)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -2982,6 +3040,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Fixed", @@ -3000,8 +3059,8 @@ describe("repos tools", () => { it("should filter threads by author email", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3024,6 +3083,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorEmail: "john@example.com", @@ -3044,8 +3104,8 @@ describe("repos tools", () => { it("should filter threads by author email (case-insensitive)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3063,6 +3123,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorEmail: "john@example.com", @@ -3080,8 +3141,8 @@ describe("repos tools", () => { it("should filter threads by author display name", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3104,6 +3165,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorDisplayName: "John", @@ -3124,8 +3186,8 @@ describe("repos tools", () => { it("should filter threads by author display name (case-insensitive)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3143,6 +3205,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorDisplayName: "john", @@ -3160,8 +3223,8 @@ describe("repos tools", () => { it("should filter threads by both status and author email", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3184,6 +3247,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Active", @@ -3204,8 +3268,8 @@ describe("repos tools", () => { it("should filter threads by status and author display name", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3228,6 +3292,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Active", @@ -3248,8 +3313,8 @@ describe("repos tools", () => { it("should combine all filters: status, authorEmail, and authorDisplayName", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3272,6 +3337,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Active", @@ -3294,8 +3360,8 @@ describe("repos tools", () => { it("should return empty array when no threads match filters", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3308,6 +3374,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Closed", @@ -3325,8 +3392,8 @@ describe("repos tools", () => { it("should apply pagination after filtering", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3354,6 +3421,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, status: "Active", @@ -3372,8 +3440,8 @@ describe("repos tools", () => { it("should handle threads with no comments when filtering by author", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3391,6 +3459,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorEmail: "user@example.com", @@ -3408,8 +3477,8 @@ describe("repos tools", () => { it("should handle threads with empty comments array when filtering by author", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockThreads = [ @@ -3427,6 +3496,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, authorDisplayName: "User", @@ -3446,8 +3516,8 @@ describe("repos tools", () => { it("should list pull request thread comments", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); - if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockComments = [ @@ -3471,6 +3541,7 @@ describe("repos tools", () => { mockGitApi.getComments.mockResolvedValue(mockComments); const params = { + action: "list_comments", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -3499,8 +3570,8 @@ describe("repos tools", () => { it("should list pull request thread comments with full response", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); - if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; const mockComments = [ @@ -3531,6 +3602,7 @@ describe("repos tools", () => { mockGitApi.getComments.mockResolvedValue(mockComments); const params = { + action: "list_comments", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -3552,8 +3624,8 @@ describe("repos tools", () => { it("should list branches by repository", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_branches_by_repo); - if (!call) throw new Error("repo_list_branches_by_repo tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; const mockBranches = [ @@ -3565,6 +3637,7 @@ describe("repos tools", () => { mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "list", repositoryId: "repo123", project: "test-project", top: 100, @@ -3583,14 +3656,15 @@ describe("repos tools", () => { it("should list my branches by repository", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_my_branches_by_repo); - if (!call) throw new Error("repo_list_my_branches_by_repo tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; const mockBranches = [{ name: "refs/heads/main" }, { name: "refs/heads/my-feature" }]; mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "list_mine", repositoryId: "repo123", project: "test-project", top: 100, @@ -3598,7 +3672,7 @@ describe("repos tools", () => { const result = await handler(params); - expect(mockGitApi.getRefs).toHaveBeenCalledWith("repo123", "test-project", "heads/", undefined, undefined, true, undefined, undefined, undefined); + expect(mockGitApi.getRefs).toHaveBeenCalledWith("repo123", "test-project", undefined, undefined, undefined, true, undefined, undefined, undefined); const expectedResult = ["my-feature", "main"]; expect(result.content[0].text).toBe(JSON.stringify(expectedResult, null, 2)); @@ -3609,8 +3683,8 @@ describe("repos tools", () => { it("should get repository by name", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); - if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -3620,6 +3694,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "get", project: "test-project", repositoryNameOrId: "test-repo", }; @@ -3633,8 +3708,8 @@ describe("repos tools", () => { it("should get repository by ID", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); - if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -3644,6 +3719,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "get", project: "test-project", repositoryNameOrId: "repo2", }; @@ -3656,13 +3732,14 @@ describe("repos tools", () => { it("should return error when repository not found", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); - if (!call) throw new Error("repo_get_repo_by_name_or_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; mockGitApi.getRepositories.mockResolvedValue([]); const params = { + action: "get", project: "test-project", repositoryNameOrId: "nonexistent-repo", }; @@ -3679,8 +3756,8 @@ describe("repos tools", () => { it("should get branch by name", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); - if (!call) throw new Error("repo_get_branch_by_name tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; const mockBranches = [ @@ -3690,6 +3767,7 @@ describe("repos tools", () => { mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "get", repositoryId: "repo123", project: "test-project", branchName: "main", @@ -3704,13 +3782,14 @@ describe("repos tools", () => { it("should return error message when branch not found", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); - if (!call) throw new Error("repo_get_branch_by_name tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; mockGitApi.getRefs.mockResolvedValue([]); const params = { + action: "get", repositoryId: "repo123", project: "test-project", branchName: "nonexistent", @@ -3726,8 +3805,8 @@ describe("repos tools", () => { it("should get pull request by ID", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3738,6 +3817,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockPR); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeWorkItemRefs: false, @@ -3752,8 +3832,8 @@ describe("repos tools", () => { it("should pass project parameter when provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3764,6 +3844,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockPR); const params = { + action: "get", repositoryId: "my-repo-name", pullRequestId: 456, project: "my-project", @@ -3779,13 +3860,14 @@ describe("repos tools", () => { it("should include work item refs when requested", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequest.mockResolvedValue({}); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeWorkItemRefs: true, @@ -3799,8 +3881,8 @@ describe("repos tools", () => { it("should include labels when includeLabels is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3823,6 +3905,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -3848,8 +3931,8 @@ describe("repos tools", () => { it("should not include labels when includeLabels parameter is not specified and defaults are not applied", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3861,6 +3944,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockPR); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, // includeLabels not specified, in test environment doesn't get default @@ -3877,8 +3961,8 @@ describe("repos tools", () => { it("should include labels by default when includeLabels is explicitly set to default value true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3898,6 +3982,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, // explicitly set to default value @@ -3923,8 +4008,8 @@ describe("repos tools", () => { it("should not include labels when includeLabels is false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3936,6 +4021,7 @@ describe("repos tools", () => { mockGitApi.getPullRequest.mockResolvedValue(mockPR); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: false, @@ -3952,8 +4038,8 @@ describe("repos tools", () => { it("should handle empty labels array", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -3973,6 +4059,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -3996,8 +4083,8 @@ describe("repos tools", () => { it("should handle labels with undefined names", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -4021,6 +4108,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -4042,8 +4130,8 @@ describe("repos tools", () => { it("should handle getPullRequestLabels API error gracefully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -4065,6 +4153,7 @@ describe("repos tools", () => { const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -4089,8 +4178,8 @@ describe("repos tools", () => { it("should work with both includeLabels and includeWorkItemRefs enabled", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -4110,6 +4199,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestLabels.mockResolvedValue(mockLabels); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -4135,8 +4225,8 @@ describe("repos tools", () => { it("should include changed files when includeChangedFiles is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); - if (!call) throw new Error("repo_get_pull_request_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockPR = { @@ -4154,6 +4244,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: mockChangeEntries }); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true, @@ -4174,13 +4265,13 @@ describe("repos tools", () => { it("should not fetch changed files when includeChangedFiles is false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.getPullRequest.mockResolvedValue(mockPR); - const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: false }); + const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: false }); expect(mockGitApi.getPullRequestIterations).not.toHaveBeenCalled(); expect(result.content[0].text).toBe(JSON.stringify(mockPR, null, 2)); @@ -4189,13 +4280,13 @@ describe("repos tools", () => { it("should not fetch changed files when includeChangedFiles is not specified", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.getPullRequest.mockResolvedValue(mockPR); - const result = await handler({ repositoryId: "repo123", pullRequestId: 123 }); + const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123 }); expect(mockGitApi.getPullRequestIterations).not.toHaveBeenCalled(); expect(result.content[0].text).toBe(JSON.stringify(mockPR, null, 2)); @@ -4204,14 +4295,14 @@ describe("repos tools", () => { it("should handle empty iterations when includeChangedFiles is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.getPullRequest.mockResolvedValue(mockPR); mockGitApi.getPullRequestIterations.mockResolvedValue([]); - const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); + const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); const resultData = JSON.parse(result.content[0].text); expect(resultData.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 }); @@ -4221,7 +4312,7 @@ describe("repos tools", () => { it("should handle getPullRequestIterationChanges API error gracefully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; @@ -4231,7 +4322,7 @@ describe("repos tools", () => { const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); - const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); + const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); expect(consoleSpy).toHaveBeenCalledWith("Error fetching PR changed files: API Error: Changes not accessible"); @@ -4245,14 +4336,14 @@ describe("repos tools", () => { it("should handle iteration with null id when includeChangedFiles is true", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.getPullRequest.mockResolvedValue(mockPR); mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: null }]); - const result = await handler({ repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); + const result = await handler({ action: "get", repositoryId: "repo123", pullRequestId: 123, includeChangedFiles: true }); const resultData = JSON.parse(result.content[0].text); expect(resultData.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 }); @@ -4262,7 +4353,7 @@ describe("repos tools", () => { it("should work with both includeLabels and includeChangedFiles enabled", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; const mockPR = { @@ -4280,6 +4371,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: mockChangeEntries }); const result = await handler({ + action: "get", repositoryId: "repo123", pullRequestId: 123, includeLabels: true, @@ -4295,1137 +4387,19 @@ describe("repos tools", () => { }); }); - describe("repo_get_pull_request_changes", () => { - it("should get pull request changes with explicit iteration ID", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: "edit", - }, - { - item: { path: "/src/file2.ts" }, - changeType: "add", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - }; - - const result = await handler(params); - - expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("12345678-1234-1234-1234-123456789012", 456, 1, undefined, undefined, undefined, undefined); - expect(result.content[0].text).toBe(JSON.stringify(mockChanges, null, 2)); - }); - - it("should get pull request changes for latest iteration when not specified", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIterations = [ - { id: 1, createdDate: new Date("2024-01-01") }, - { id: 2, createdDate: new Date("2024-01-02") }, - { id: 3, createdDate: new Date("2024-01-03") }, - ]; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/README.md" }, - changeType: "edit", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - mockGitApi.getPullRequestIterations.mockResolvedValue(mockIterations); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - }; - - const result = await handler(params); - - expect(mockGitApi.getPullRequestIterations).toHaveBeenCalledWith("12345678-1234-1234-1234-123456789012", 456, undefined); - expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith( - "12345678-1234-1234-1234-123456789012", - 456, - 3, // Latest iteration ID - undefined, - undefined, - undefined, - undefined - ); - expect(result.content[0].text).toBe(JSON.stringify(mockChanges, null, 2)); - }); - - it("should support pagination parameters", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockChanges = { - changeEntries: [], - nextSkip: 50, - nextTop: 50, - }; - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - top: 50, - skip: 0, - }; - - await handler(params); - - expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("12345678-1234-1234-1234-123456789012", 456, 1, undefined, 50, 0, undefined); - }); - - it("should support compareTo parameter for iteration comparison", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/updated.ts" }, - changeType: "edit", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 2, - compareTo: 1, - }; - - await handler(params); - - expect(mockGitApi.getPullRequestIterationChanges).toHaveBeenCalledWith("12345678-1234-1234-1234-123456789012", 456, 2, undefined, undefined, undefined, 1); - }); - - it("should handle error when no iterations found", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - mockGitApi.getPullRequestIterations.mockResolvedValue([]); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - }; - - const result = await handler(params); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe("No iterations found for this pull request."); - }); - - it("should handle API errors gracefully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - mockGitApi.getPullRequestIterationChanges.mockRejectedValue(new Error("API Error: Access denied")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - }; - - const result = await handler(params); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toBe("Error getting pull request changes: API Error: Access denied"); - }); - - it("should include actual diff content by default", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: "edit", - }, - { - item: { path: "/src/file2.ts" }, - changeType: "add", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [ - { - changeType: 2, // Edit - modifiedLineStart: 10, - modifiedLineCount: 2, - originalLineStart: 10, - originalLineCount: 1, - }, - ], - }, - { - path: "src/file2.ts", - lineDiffBlocks: [ - { - changeType: 1, // Add - modifiedLineStart: 1, - modifiedLineCount: 5, - originalLineStart: 0, - originalLineCount: 0, - }, - ], - }, - ]; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - }; - - const result = await handler(params); - - expect(mockGitApi.getPullRequestIteration).toHaveBeenCalledWith("12345678-1234-1234-1234-123456789012", 456, 1, undefined); - expect(mockGitApi.getFileDiffs).toHaveBeenCalledWith( - { - baseVersionCommit: "def456", - targetVersionCommit: "abc123", - fileDiffParams: [ - { path: "src/file1.ts", originalPath: "src/file1.ts" }, - { path: "src/file2.ts", originalPath: "src/file2.ts" }, - ], - }, - "", - "12345678-1234-1234-1234-123456789012" - ); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0].diff).toEqual(mockFileDiffs[0]); - expect(parsedResult.changeEntries[1].diff).toEqual(mockFileDiffs[1]); - }); - - it("should skip diff content when includeDiffs is false", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: "edit", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: false, - }; - - const result = await handler(params); - - expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); - expect(result.content[0].text).toBe(JSON.stringify(mockChanges, null, 2)); - }); - - it("should handle diff fetch errors gracefully and return metadata", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: "edit", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockRejectedValue(new Error("Diff API unavailable")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult._diffError).toContain("Diff API unavailable"); - expect(parsedResult._note).toBe("Returned metadata only"); - expect(parsedResult.changeEntries).toEqual(mockChanges.changeEntries); - }); - - it("should include actual line content when includeLineContent is true", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: "edit", - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [ - { - changeType: 3, // Edit - modifiedLineNumberStart: 10, - modifiedLinesCount: 2, - originalLineNumberStart: 10, - originalLinesCount: 1, - }, - ], - }, - ]; - - // Mock file content streams - const baseFileContent = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nold line 10\nline 11\nline 12"; - const targetFileContent = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nnew line 10\nnew line 11\nline 12"; - - const { Readable } = await import("stream"); - const baseStream = new Readable(); - baseStream.push(baseFileContent); - baseStream.push(null); - - const targetStream = new Readable(); - targetStream.push(targetFileContent); - targetStream.push(null); - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - mockGitApi.getItemText.mockResolvedValueOnce(baseStream).mockResolvedValueOnce(targetStream); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - const result = await handler(params); - - expect(mockGitApi.getItemText).toHaveBeenCalledTimes(2); - - const parsedResult = JSON.parse(result.content[0].text); - const diffBlock = parsedResult.changeEntries[0].diff.lineDiffBlocks[0]; - - expect(diffBlock.originalLines).toEqual(["old line 10"]); - expect(diffBlock.modifiedLines).toEqual(["new line 10", "new line 11"]); - }); - - it("should skip line content for entries without diff blocks", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: 2, // Edit type - }, - { - item: { path: "/src/file2.ts" }, - changeType: 1, // Add type (filtered out from diffs) - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [], // Empty diff blocks - }, - ]; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - mockGitApi.getItemText.mockResolvedValue(null); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - const result = await handler(params); - - // getItemText should be called once for the Add entry (to fetch synthetic diff) - // but not for the Edit entry with empty diff blocks - expect(mockGitApi.getItemText).toHaveBeenCalledTimes(1); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0].diff.lineDiffBlocks).toEqual([]); - }); - - it("should skip line content for entries without path", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: undefined }, // No path - changeType: 2, - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "", - lineDiffBlocks: [ - { - changeType: 3, - modifiedLineNumberStart: 1, - modifiedLinesCount: 1, - originalLineNumberStart: 1, - originalLinesCount: 1, - }, - ], - }, - ]; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - await handler(params); - - // getItemText should not be called for entries without path - expect(mockGitApi.getItemText).not.toHaveBeenCalled(); - }); - - it("should handle getItemText errors gracefully", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: 2, - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [ - { - changeType: 3, - modifiedLineNumberStart: 10, - modifiedLinesCount: 1, - originalLineNumberStart: 10, - originalLinesCount: 1, - }, - ], - }, - ]; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - // Mock getItemText to return null (simulating catch returning null) - mockGitApi.getItemText.mockResolvedValue(null); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - const result = await handler(params); - - // Should handle null content gracefully - expect(mockGitApi.getItemText).toHaveBeenCalledTimes(2); - - const parsedResult = JSON.parse(result.content[0].text); - const diffBlock = parsedResult.changeEntries[0].diff.lineDiffBlocks[0]; - - // Empty strings split into array with single empty string - expect(diffBlock.originalLines).toEqual([]); - expect(diffBlock.modifiedLines).toEqual([]); - }); - - it("should detect and handle Azure DevOps error JSON in base file response", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: 2, - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [ - { - changeType: 3, - modifiedLineNumberStart: 10, - modifiedLinesCount: 1, - originalLineNumberStart: 10, - originalLinesCount: 1, - }, - ], - }, - ]; - - // Mock error JSON response as stream - const errorJson = '{"$id":"1","innerException":null,"message":"TF401175: Version not found"}'; - const { Readable } = await import("stream"); - const errorStream = new Readable(); - errorStream.push(errorJson); - errorStream.push(null); - - const validStream = new Readable(); - validStream.push("valid file content"); - validStream.push(null); - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - mockGitApi.getItemText.mockResolvedValueOnce(errorStream).mockResolvedValueOnce(validStream); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch base file content"); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("TF401175"); - }); - - it("should detect and handle Azure DevOps error JSON in target file response", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { - item: { path: "/src/file1.ts" }, - changeType: 2, - }, - ], - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file1.ts", - lineDiffBlocks: [ - { - changeType: 3, - modifiedLineNumberStart: 10, - modifiedLinesCount: 1, - originalLineNumberStart: 10, - originalLinesCount: 1, - }, - ], - }, - ]; - - // Mock error JSON response as stream for target file - const errorJson = '{"$id":"1","innerException":null,"message":"TF401175: Version not found"}'; - const { Readable } = await import("stream"); - const validStream = new Readable(); - validStream.push("valid base content"); - validStream.push(null); - - const errorStream = new Readable(); - errorStream.push(errorJson); - errorStream.push(null); - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - mockGitApi.getItemText.mockResolvedValueOnce(validStream).mockResolvedValueOnce(errorStream); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch target file content"); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("TF401175"); - }); - - it("should return file content for PRs with only added files (no modified files)", async () => { - // Regression test: when all changes are Add, fileDiffParams is empty, so getFileDiffs - // was never called and the code fell through to the metadata-only fallback, losing the - // includeLineContent enrichment for added files. - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [ - { item: { path: "/Testfolder.md" }, originalPath: null, changeType: 1 }, // Add - { item: { path: "/New Folder/Addition 1" }, originalPath: null, changeType: 1 }, // Add - { item: { path: "/New Folder/Addition 2" }, originalPath: null, changeType: 1 }, // Add - ], - nextSkip: 0, - nextTop: 0, - }; - - const { Readable } = await import("stream"); - const makeStream = (content: string) => { - const s = new Readable(); - s.push(content); - s.push(null); - return s; - }; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getItemText.mockResolvedValueOnce(makeStream("# Testfolder\nHello")).mockResolvedValueOnce(makeStream("Addition 1 content")).mockResolvedValueOnce(makeStream("Addition 2 content")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - // getFileDiffs must NOT have been called (no modified files) - expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); - // getItemText must have been called once per added file - expect(mockGitApi.getItemText).toHaveBeenCalledTimes(3); - - const parsedResult = JSON.parse(result.content[0].text); - // Each added entry should have a synthetic diff with the full file content, - // path set to the new file path, and originalPath null (file didn't exist before). - expect(parsedResult.changeEntries[0].diff.path).toBe("Testfolder.md"); - expect(parsedResult.changeEntries[0].diff.originalPath).toBeNull(); - expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].modifiedLines).toEqual(["# Testfolder", "Hello"]); - expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 1 content"]); - expect(parsedResult.changeEntries[2].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 2 content"]); - }); - - it("should return file content for PRs with only deleted files (no modified files)", async () => { - // Regression test: mirror of the addition case for deletions — when all changes are Delete, - // fileDiffParams is empty so the enrichment block was previously skipped entirely. - // Also covers the ADO behaviour where item.path is null for deletions (path lives in originalPath). - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - // ADO real shape: item.path is null for deletions; path is in originalPath only - const mockChanges = { - changeEntries: [ - { originalPath: "/src/removed.ts", item: { path: null }, changeType: 16 }, // Delete - { originalPath: "/src/gone.ts", item: { path: null }, changeType: 16 }, // Delete - ], - nextSkip: 0, - nextTop: 0, - }; - - const { Readable } = await import("stream"); - const makeStream = (content: string) => { - const s = new Readable(); - s.push(content); - s.push(null); - return s; - }; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getItemText.mockResolvedValueOnce(makeStream("export const removed = true;")).mockResolvedValueOnce(makeStream("export const gone = true;")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - // getFileDiffs must NOT have been called (no modified files) - expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); - // getItemText must have been called once per deleted file, using the normalised originalPath - expect(mockGitApi.getItemText).toHaveBeenCalledTimes(2); - expect(mockGitApi.getItemText).toHaveBeenCalledWith( - "12345678-1234-1234-1234-123456789012", - "src/removed.ts", // leading slash stripped - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { version: "def456", versionType: expect.anything() } - ); - - const parsedResult = JSON.parse(result.content[0].text); - // Each deleted entry should have a synthetic diff with the removed file content, - // path null (file no longer exists) and originalPath set to the pre-deletion path. - expect(parsedResult.changeEntries[0].diff.path).toBeNull(); - expect(parsedResult.changeEntries[0].diff.originalPath).toBe("src/removed.ts"); - expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].originalLines).toEqual(["export const removed = true;"]); - expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].changeType).toBe(2); // Delete - expect(parsedResult.changeEntries[1].diff.path).toBeNull(); - expect(parsedResult.changeEntries[1].diff.originalPath).toBe("src/gone.ts"); - expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].originalLines).toEqual(["export const gone = true;"]); - }); - - it("should return error when non-GUID repositoryId is used without a project", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const params = { - repositoryId: "my-repository-name", // not a GUID - pullRequestId: 456, - iterationId: 1, - // project intentionally omitted - }; - - const result = await handler(params); - - expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("When using a repository name instead of a GUID"); - expect(mockGitApi.getPullRequestIterationChanges).not.toHaveBeenCalled(); - }); - - it("should return entry unchanged when getItemText rejects for added file (catch null path)", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add - nextSkip: 0, - nextTop: 0, - }; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - // getItemText rejects → .catch(() => null) fires → targetStream = null → return entry - mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0].diff).toBeNull(); - expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); - }); - - it("should return _contentFetchError when streamToString throws for added file", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add - nextSkip: 0, - nextTop: 0, - }; - - const { Readable } = await import("stream"); - const errorStream = new Readable({ - read() { - this.emit("error", new Error("Stream read error")); - }, - }); - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getItemText.mockResolvedValueOnce(errorStream); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch added file content"); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); - }); - - it("should return entry unchanged when getItemText rejects for deleted file (catch null path)", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete - nextSkip: 0, - nextTop: 0, - }; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - // getItemText rejects → .catch(() => null) fires → baseStream = null → return entry - mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0].diff).toBeNull(); - expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); - }); - - it("should return _contentFetchError when streamToString throws for deleted file", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete - nextSkip: 0, - nextTop: 0, - }; - - const { Readable } = await import("stream"); - const errorStream = new Readable({ - read() { - this.emit("error", new Error("Stream read error")); - }, - }); - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getItemText.mockResolvedValueOnce(errorStream); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch deleted file content"); - expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); - }); - - it("should handle getItemText rejection for modified file via catch null (empty lines result)", async () => { - configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); - if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); - const [, , , handler] = call; - - const mockIteration = { - id: 1, - sourceRefCommit: { commitId: "abc123" }, - commonRefCommit: { commitId: "def456" }, - }; - - const mockChanges = { - changeEntries: [{ item: { path: "/src/file.ts" }, changeType: 2 }], // Edit - nextSkip: 0, - nextTop: 0, - }; - - const mockFileDiffs = [ - { - path: "src/file.ts", - lineDiffBlocks: [ - { - changeType: 3, - modifiedLineNumberStart: 10, - modifiedLinesCount: 1, - originalLineNumberStart: 10, - originalLinesCount: 1, - }, - ], - }, - ]; - - mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); - mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); - mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); - // Both getItemText calls reject → .catch(() => null) fires → null content → empty lines - mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")).mockRejectedValueOnce(new Error("Network error")); - - const params = { - repositoryId: "12345678-1234-1234-1234-123456789012", - pullRequestId: 456, - iterationId: 1, - includeDiffs: true, - includeLineContent: true, - }; - - const result = await handler(params); - - const parsedResult = JSON.parse(result.content[0].text); - const diffBlock = parsedResult.changeEntries[0].diff.lineDiffBlocks[0]; - expect(diffBlock.originalLines).toEqual([]); - expect(diffBlock.modifiedLines).toEqual([]); - }); - }); - describe("repo_reply_to_comment", () => { it("should reply to comment successfully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); - if (!call) throw new Error("repo_reply_to_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockComment = { id: 789, content: "Reply content" }; mockGitApi.createComment.mockResolvedValue(mockComment); const params = { + action: "reply", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5441,14 +4415,15 @@ describe("repos tools", () => { it("should return full response when requested", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); - if (!call) throw new Error("repo_reply_to_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockComment = { id: 789, content: "Reply content" }; mockGitApi.createComment.mockResolvedValue(mockComment); const params = { + action: "reply", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5464,13 +4439,14 @@ describe("repos tools", () => { it("should return error when comment creation fails", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); - if (!call) throw new Error("repo_reply_to_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; mockGitApi.createComment.mockResolvedValue(null); const params = { + action: "reply", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5488,14 +4464,15 @@ describe("repos tools", () => { it("should create pull request thread with basic content", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123, status: 1 }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "New thread content", @@ -5520,14 +4497,15 @@ describe("repos tools", () => { it("should create pull request thread with file context and position", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Thread with position", @@ -5561,14 +4539,15 @@ describe("repos tools", () => { it("should normalize file path by adding leading slash if missing", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Thread with normalized path", @@ -5596,14 +4575,15 @@ describe("repos tools", () => { it("should preserve file path if it already starts with slash", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123 }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Thread with existing slash", @@ -5631,11 +4611,12 @@ describe("repos tools", () => { it("should throw error for invalid line numbers", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Thread content", @@ -5654,8 +4635,8 @@ describe("repos tools", () => { it("should update thread status to Active", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5669,6 +4650,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5706,8 +4688,8 @@ describe("repos tools", () => { it("should update thread status to Fixed", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5721,6 +4703,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5746,8 +4729,8 @@ describe("repos tools", () => { it("should update thread status to WontFix", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5761,6 +4744,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5786,8 +4770,8 @@ describe("repos tools", () => { it("should update thread status to Closed", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5801,6 +4785,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 100, @@ -5826,8 +4811,8 @@ describe("repos tools", () => { it("should update thread status to ByDesign", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5841,6 +4826,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 200, @@ -5866,8 +4852,8 @@ describe("repos tools", () => { it("should update thread status to Pending", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5881,6 +4867,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 300, @@ -5906,11 +4893,12 @@ describe("repos tools", () => { it("should return error when no fields provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5921,19 +4909,20 @@ describe("repos tools", () => { expect(mockGitApi.updateThread).not.toHaveBeenCalled(); expect(result.isError).toBe(true); - expect(result.content[0].text).toBe("Error: At least one field (status) must be provided for update."); + expect(result.content[0].text).toBe("status is required for update_status"); }); it("should return error when thread update fails", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; mockGitApi.updateThread.mockResolvedValue(null); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -5950,8 +4939,8 @@ describe("repos tools", () => { it("should filter deleted comments from response", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); - if (!call) throw new Error("repo_update_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { @@ -5969,6 +4958,7 @@ describe("repos tools", () => { mockGitApi.updateThread.mockResolvedValue(mockThread); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -6022,7 +5012,7 @@ describe("repos tools", () => { function getHandler() { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; return handler; @@ -6232,8 +5222,8 @@ describe("repos tools", () => { it("should list pull requests by commits successfully", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); - if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockQueryResult = { @@ -6247,6 +5237,7 @@ describe("repos tools", () => { mockGitApi.getPullRequestQuery.mockResolvedValue(mockQueryResult); const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123", "def456"], @@ -6274,13 +5265,14 @@ describe("repos tools", () => { it("should handle pull request query errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); - if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue(new Error("Query Error")); const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123"], @@ -6289,7 +5281,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Error querying pull requests by commits: Query Error"); + expect(result.content[0].text).toContain("Error with pull request operation: Query Error"); }); }); @@ -6297,14 +5289,15 @@ describe("repos tools", () => { it("should cast an Approved vote", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestReviewer.mockResolvedValue({ id: "user123", isRequired: true }); mockGitApi.createPullRequestReviewer.mockResolvedValue({}); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", @@ -6322,14 +5315,15 @@ describe("repos tools", () => { it("should cast a Rejected vote", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestReviewer.mockResolvedValue({ id: "user123", isRequired: false }); mockGitApi.createPullRequestReviewer.mockResolvedValue({}); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", @@ -6344,14 +5338,15 @@ describe("repos tools", () => { it("should cast a vote when reviewer does not exist yet", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestReviewer.mockRejectedValue(new Error("Reviewer not found")); mockGitApi.createPullRequestReviewer.mockResolvedValue({}); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", @@ -6366,60 +5361,69 @@ describe("repos tools", () => { it("should throw when authenticated user ID is missing", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGetCurrentUserDetails.mockResolvedValue({ authenticatedUser: { id: undefined } } as any); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", vote: "NoVote" as const, }; - await expect(handler(params)).rejects.toThrow("Could not determine authenticated user ID."); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Could not determine authenticated user ID."); expect(mockGitApi.createPullRequestReviewer).not.toHaveBeenCalled(); }); it("should propagate API errors from createPullRequestReviewer", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestReviewer.mockResolvedValue({ id: "user123" }); mockGitApi.createPullRequestReviewer.mockRejectedValue(new Error("Reviewer update failed")); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", vote: "WaitingForAuthor" as const, }; - await expect(handler(params)).rejects.toThrow("Reviewer update failed"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Reviewer update failed"); }); it("should propagate API errors from getPullRequestReviewer", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.vote_pull_request); - if (!call) throw new Error("repo_vote_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestReviewer.mockRejectedValue(new Error("Reviewer lookup failed")); const params = { + action: "vote", repositoryId: "repo123", pullRequestId: 427, project: "test-project", vote: "WaitingForAuthor" as const, }; - await expect(handler(params)).rejects.toThrow("Reviewer lookup failed"); + const result = await handler(params); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Reviewer lookup failed"); expect(mockGitApi.createPullRequestReviewer).not.toHaveBeenCalled(); }); }); @@ -6428,8 +5432,8 @@ describe("repos tools", () => { it("should handle Completed status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGetCurrentUserDetails.mockResolvedValue({ @@ -6439,6 +5443,7 @@ describe("repos tools", () => { mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", status: "Completed", top: 100, @@ -6453,13 +5458,14 @@ describe("repos tools", () => { it("should handle All status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", status: "All", top: 100, @@ -6474,13 +5480,14 @@ describe("repos tools", () => { it("should handle NotSet status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", status: "NotSet", top: 100, @@ -6495,13 +5502,14 @@ describe("repos tools", () => { it("should handle Abandoned status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", status: "Abandoned", top: 100, @@ -6516,11 +5524,12 @@ describe("repos tools", () => { it("should throw error for unknown status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const params = { + action: "list", repositoryId: "repo123", status: "InvalidStatus", top: 100, @@ -6537,14 +5546,15 @@ describe("repos tools", () => { it("should handle getUserIdFromEmail error in list_pull_requests_by_repo", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock getUserIdFromEmail to throw an error mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", repositoryId: "repo123", created_by_user: "nonexistent@example.com", status: "Active", @@ -6561,14 +5571,15 @@ describe("repos tools", () => { it("should handle getUserIdFromEmail error in list_pull_requests_by_project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; // Mock getUserIdFromEmail to throw an error mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", project: "test-project", created_by_user: "nonexistent@example.com", status: "Active", @@ -6585,7 +5596,7 @@ describe("repos tools", () => { it("should handle commit search error in search_commits", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -6598,13 +5609,14 @@ describe("repos tools", () => { it("should handle thread creation error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; mockGitApi.createThread.mockRejectedValue(new Error("Thread creation failed")); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6612,7 +5624,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error creating pull request thread: Thread creation failed" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Thread creation failed" }], isError: true, }); }); @@ -6620,13 +5632,14 @@ describe("repos tools", () => { it("should handle comment reply error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); - if (!call) throw new Error("repo_reply_to_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; mockGitApi.createComment.mockRejectedValue(new Error("Comment creation failed")); const params = { + action: "reply", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -6635,7 +5648,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error replying to comment: Comment creation failed" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Comment creation failed" }], isError: true, }); }); @@ -6645,11 +5658,12 @@ describe("repos tools", () => { it("should handle invalid line numbers in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6667,11 +5681,12 @@ describe("repos tools", () => { it("should reject invalid rightFileStartOffset in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6690,11 +5705,12 @@ describe("repos tools", () => { it("should reject rightFileEndLine without rightFileStartLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6712,11 +5728,12 @@ describe("repos tools", () => { it("should reject invalid rightFileEndLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6736,11 +5753,12 @@ describe("repos tools", () => { it("should reject rightFileEndLine without rightFileEndOffset in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6759,11 +5777,12 @@ describe("repos tools", () => { it("should reject invalid rightFileEndOffset in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6783,11 +5802,12 @@ describe("repos tools", () => { it("should reject rightFileEndOffset without rightFileEndLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6806,11 +5826,12 @@ describe("repos tools", () => { it("should require both rightFileEndLine and rightFileEndOffset when rightFileStartLine and rightFileStartOffset are specified", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6830,11 +5851,12 @@ describe("repos tools", () => { it("should reject rightFileEndOffset less than rightFileStartOffset on same line in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -6855,8 +5877,8 @@ describe("repos tools", () => { it("should handle create_pull_request with undefined forkSourceRepositoryId", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockPR = { @@ -6878,6 +5900,7 @@ describe("repos tools", () => { }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -6929,8 +5952,8 @@ describe("repos tools", () => { it("should handle trimComments with undefined comments", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; // Mock threads with undefined comments to test the trimComments function @@ -6954,6 +5977,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, top: 10, @@ -6971,8 +5995,8 @@ describe("repos tools", () => { it("should handle trimComments with deleted comments", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; // Mock threads with deleted comments to test the trimComments function @@ -7002,6 +6026,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, top: 10, @@ -7019,8 +6044,8 @@ describe("repos tools", () => { it("should handle list_repos_by_project without repoNameFilter", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); - if (!call) throw new Error("repo_list_repos_by_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -7031,6 +6056,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "list", project: "test-project", top: 100, skip: 0, @@ -7048,8 +6074,8 @@ describe("repos tools", () => { it("should handle branches.find returning undefined (branch name mismatch)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); - if (!call) throw new Error("repo_get_branch_by_name tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; // Mock branches that don't match the requested branch name @@ -7061,6 +6087,7 @@ describe("repos tools", () => { mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "get", repositoryId: "repo123", branchName: "nonexistent-branch", // This branch doesn't exist in the mock data }; @@ -7074,8 +6101,8 @@ describe("repos tools", () => { it("should handle branch.name with exact branchName match", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); - if (!call) throw new Error("repo_get_branch_by_name tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; // Mock branches where one matches exactly with the branchName (second condition in the find) @@ -7087,6 +6114,7 @@ describe("repos tools", () => { mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "get", repositoryId: "repo123", branchName: "main", }; @@ -7100,13 +6128,14 @@ describe("repos tools", () => { it("should handle list_pull_requests_by_repo with created_by_user and i_am_reviewer both false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); const params = { + action: "list", repositoryId: "repo123", status: "Active", // Provide explicit status to avoid undefined created_by_me: false, @@ -7133,13 +6162,14 @@ describe("repos tools", () => { it("should handle list_pull_requests_by_project with created_by_user and i_am_reviewer both false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); const params = { + action: "list", project: "test-project", status: "Active", // Provide explicit status to avoid undefined created_by_me: false, @@ -7165,8 +6195,8 @@ describe("repos tools", () => { it("should handle comments?.flatMap with null/undefined branch in branchesFilterOutIrrelevantProperties", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_branches_by_repo); - if (!call) throw new Error("repo_list_branches_by_repo tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); + if (!call) throw new Error("repo_branch tool not registered"); const [, , , handler] = call; // Mock branches with some having null/undefined names to test the flatMap filter @@ -7181,6 +6211,7 @@ describe("repos tools", () => { mockGitApi.getRefs.mockResolvedValue(mockBranches); const params = { + action: "list", repositoryId: "repo123", }; @@ -7194,14 +6225,15 @@ describe("repos tools", () => { it("should handle rightFileStartOffset without validation error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123, status: 1, comments: [] }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7236,14 +6268,15 @@ describe("repos tools", () => { it("should handle rightFileEndOffset without validation error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 123, status: 1, comments: [] }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7277,7 +6310,7 @@ describe("repos tools", () => { it("should handle search_commits with branch filter", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -7297,7 +6330,7 @@ describe("repos tools", () => { it("should handle search_commits without branch filter", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -7317,11 +6350,12 @@ describe("repos tools", () => { it("should handle rightFileEndLine without rightFileStartLine", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7339,11 +6373,12 @@ describe("repos tools", () => { it("should handle invalid rightFileEndLine value", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7362,11 +6397,12 @@ describe("repos tools", () => { it("should handle invalid rightFileStartOffset value", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7385,11 +6421,12 @@ describe("repos tools", () => { it("should handle invalid rightFileEndOffset value", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7409,11 +6446,12 @@ describe("repos tools", () => { it("should test pullRequestStatusStringToInt with unknown status", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const params = { + action: "list", repositoryId: "repo123", status: "UnknownStatus" as "Active", // Invalid status that should trigger the default case created_by_me: false, @@ -7428,8 +6466,8 @@ describe("repos tools", () => { it("should handle threads?.sort with undefined id values", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); - if (!call) throw new Error("repo_list_pull_request_threads tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; // Mock threads with undefined/null id values to test the sort function @@ -7460,6 +6498,7 @@ describe("repos tools", () => { mockGitApi.getThreads.mockResolvedValue(mockThreads); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, top: 10, @@ -7475,8 +6514,8 @@ describe("repos tools", () => { it("should handle comments?.sort with undefined id values", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); - if (!call) throw new Error("repo_list_pull_request_thread_comments tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); + if (!call) throw new Error("repo_pull_request_thread tool not registered"); const [, , , handler] = call; // Mock comments with undefined/null id values to test the sort function @@ -7504,6 +6543,7 @@ describe("repos tools", () => { mockGitApi.getComments.mockResolvedValue(mockComments); const params = { + action: "list_comments", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -7520,14 +6560,15 @@ describe("repos tools", () => { it("should handle workItemRefs when workItems is undefined", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -7550,14 +6591,15 @@ describe("repos tools", () => { it("should handle workItemRefs when workItems is provided", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); - if (!call) throw new Error("repo_create_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); const [, , , handler] = call; const mockPR = { pullRequestId: 123, title: "Test PR" }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -7580,14 +6622,15 @@ describe("repos tools", () => { it("should handle empty repoNameFilter in list_repos_by_project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); - if (!call) throw new Error("repo_list_repos_by_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [{ id: "repo1", name: "Repository 1", isDisabled: false, isFork: false, isInMaintenance: false, webUrl: "url1", size: 1024 }]; mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "list", project: "test-project", repoNameFilter: "", // Empty string - should use all repositories top: 100, @@ -7605,13 +6648,14 @@ describe("repos tools", () => { it("should handle getUserIdFromEmail error with created_by_user in list_pull_requests_by_repo", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", repositoryId: "repo123", created_by_user: "nonexistent@example.com", status: "Active", @@ -7628,13 +6672,14 @@ describe("repos tools", () => { it("should handle getUserIdFromEmail error with created_by_user in list_pull_requests_by_project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); const params = { + action: "list", project: "test-project", created_by_user: "nonexistent@example.com", status: "Active", @@ -7651,11 +6696,12 @@ describe("repos tools", () => { it("should handle rightFileEndOffset set without rightFileEndLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7675,13 +6721,14 @@ describe("repos tools", () => { it("should handle error in list_pull_requests_by_commits", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); - if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue(new Error("API error")); const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123", "def456"], @@ -7691,20 +6738,21 @@ describe("repos tools", () => { const result = await handler(params); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Error querying pull requests by commits: API error"); + expect(result.content[0].text).toContain("Error with pull request operation: API error"); }); it("should handle different queryType values in list_pull_requests_by_commits", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); - if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; const mockQueryResult = { results: [] }; mockGitApi.getPullRequestQuery.mockResolvedValue(mockQueryResult); const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123"], @@ -7731,8 +6779,8 @@ describe("repos tools", () => { it("should handle repositories with null/undefined names in sorting", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); - if (!call) throw new Error("repo_list_repos_by_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); + if (!call) throw new Error("repo_repository tool not registered"); const [, , , handler] = call; const mockRepos = [ @@ -7743,6 +6791,7 @@ describe("repos tools", () => { mockGitApi.getRepositories.mockResolvedValue(mockRepos); const params = { + action: "list", project: "test-project", top: 100, skip: 0, @@ -7758,13 +6807,14 @@ describe("repos tools", () => { it("should handle non-Error exceptions in list_pull_requests_by_repo", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception const params = { + action: "list", repositoryId: "repo123", created_by_user: "nonexistent@example.com", status: "Active", @@ -7781,13 +6831,14 @@ describe("repos tools", () => { it("should handle non-Error exceptions in list_pull_requests_by_project", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); - if (!call) throw new Error("repo_list_pull_requests_by_repo_or_project tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception const params = { + action: "list", project: "test-project", created_by_user: "nonexistent@example.com", status: "Active", @@ -7804,13 +6855,14 @@ describe("repos tools", () => { it("should handle non-Error exceptions in list_pull_requests_by_commits", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); - if (!call) throw new Error("repo_list_pull_requests_by_commits tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("repo_pull_request tool not registered"); const [, , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue("String error"); // Non-Error exception const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123", "def456"], @@ -7820,17 +6872,18 @@ describe("repos tools", () => { const result = await handler(params); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("Error querying pull requests by commits: Unknown error occurred"); + expect(result.content[0].text).toContain("Error with pull request operation: Unknown error occurred"); }); it("should handle invalid rightFileEndOffset with rightFileEndLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7850,7 +6903,7 @@ describe("repos tools", () => { it("should handle network errors in search_commits", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -7863,14 +6916,15 @@ describe("repos tools", () => { it("should handle valid rightFileEndOffset with rightFileEndLine in create_pull_request_thread", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); - if (!call) throw new Error("repo_create_pull_request_thread tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); + if (!call) throw new Error("repo_pull_request_thread_write tool not registered"); const [, , , handler] = call; const mockThread = { id: 1, status: CommentThreadStatus.Active }; mockGitApi.createThread.mockResolvedValue(mockThread); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -7904,7 +6958,7 @@ describe("repos tools", () => { it("should search commits with author and date filters via Search API", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -7934,7 +6988,7 @@ describe("repos tools", () => { it("should search commits across multiple repos", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); if (!call) throw new Error("repo_search_commits tool not registered"); const [, , , handler] = call; @@ -7969,12 +7023,13 @@ describe("repos tools", () => { describe("repo_create_pull_request error handling", () => { it("should handle connection errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; connectionProvider.mockRejectedValue(new Error("Connection failed")); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -7984,19 +7039,20 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error creating pull request: Connection failed" }], + content: [{ type: "text", text: "Error with pull request write operation: Connection failed" }], isError: true, }); }); it("should handle API errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; mockGitApi.createPullRequest.mockRejectedValue(new Error("API error: Invalid branch")); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -8006,7 +7062,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error creating pull request: API error: Invalid branch" }], + content: [{ type: "text", text: "Error with pull request write operation: API error: Invalid branch" }], isError: true, }); }); @@ -8015,7 +7071,7 @@ describe("repos tools", () => { describe("repo_create_branch error handling", () => { it("should handle connection errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); const [, , , handler] = call; connectionProvider.mockRejectedValue(new Error("Connection timeout")); @@ -8035,7 +7091,7 @@ describe("repos tools", () => { it("should handle updateRefs API errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_branch); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_create_branch); const [, , , handler] = call; // Mock successful source branch lookup @@ -8062,12 +7118,13 @@ describe("repos tools", () => { describe("repo_update_pull_request error handling", () => { it("should handle API errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; mockGitApi.updatePullRequest.mockRejectedValue(new Error("Pull request not found")); const params = { + action: "update", repositoryId: "repo123", pullRequestId: 456, title: "Updated Title", @@ -8076,7 +7133,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request: Pull request not found" }], + content: [{ type: "text", text: "Error with pull request write operation: Pull request not found" }], isError: true, }); }); @@ -8085,44 +7142,46 @@ describe("repos tools", () => { describe("repo_update_pull_request_reviewers error handling", () => { it("should handle add reviewers error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; mockGitApi.createPullRequestReviewers.mockRejectedValue(new Error("Invalid reviewer ID")); const params = { + action: "update_reviewers", repositoryId: "repo123", pullRequestId: 456, reviewerIds: ["user1"], - action: "add" as const, + reviewerAction: "add" as const, }; const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request reviewers: Invalid reviewer ID" }], + content: [{ type: "text", text: "Error with pull request write operation: Invalid reviewer ID" }], isError: true, }); }); it("should handle remove reviewers error", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_reviewers); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; mockGitApi.deletePullRequestReviewer.mockRejectedValue(new Error("Reviewer not found")); const params = { + action: "update_reviewers", repositoryId: "repo123", pullRequestId: 456, reviewerIds: ["user1"], - action: "remove" as const, + reviewerAction: "remove" as const, }; const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request reviewers: Reviewer not found" }], + content: [{ type: "text", text: "Error with pull request write operation: Reviewer not found" }], isError: true, }); }); @@ -8131,19 +7190,20 @@ describe("repos tools", () => { describe("repo_list_repos_by_project error handling", () => { it("should handle repository listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_repos_by_project); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); const [, , , handler] = call; mockGitApi.getRepositories.mockRejectedValue(new Error("Project not found")); const params = { + action: "list", project: "nonexistent-project", }; const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing repositories: Project not found" }], + content: [{ type: "text", text: "Error with repository operation: Project not found" }], isError: true, }); }); @@ -8152,12 +7212,13 @@ describe("repos tools", () => { describe("repo_list_pull_requests_by_repo_or_project error handling", () => { it("should handle pull request listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_repo_or_project); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; mockGitApi.getPullRequests.mockRejectedValue(new Error("Repository access denied")); const params = { + action: "list", repositoryId: "repo123", status: "Active", // Add required default status }; @@ -8165,7 +7226,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing pull requests: Repository access denied" }], + content: [{ type: "text", text: "Error with pull request operation: Repository access denied" }], isError: true, }); }); @@ -8174,12 +7235,13 @@ describe("repos tools", () => { describe("repo_list_pull_request_threads error handling", () => { it("should handle thread listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_threads); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); const [, , , handler] = call; mockGitApi.getThreads.mockRejectedValue(new Error("Pull request not found")); const params = { + action: "list", repositoryId: "repo123", pullRequestId: 456, }; @@ -8187,7 +7249,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing pull request threads: Pull request not found" }], + content: [{ type: "text", text: "Error with pull request thread operation: Pull request not found" }], isError: true, }); }); @@ -8196,12 +7258,13 @@ describe("repos tools", () => { describe("repo_list_pull_request_thread_comments error handling", () => { it("should handle comment listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_request_thread_comments); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread); const [, , , handler] = call; mockGitApi.getComments.mockRejectedValue(new Error("Thread not found")); const params = { + action: "list_comments", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -8210,7 +7273,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing pull request thread comments: Thread not found" }], + content: [{ type: "text", text: "Error with pull request thread operation: Thread not found" }], isError: true, }); }); @@ -8219,19 +7282,20 @@ describe("repos tools", () => { describe("repo_list_branches_by_repo error handling", () => { it("should handle branch listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_branches_by_repo); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); const [, , , handler] = call; mockGitApi.getRefs.mockRejectedValue(new Error("Repository not found")); const params = { + action: "list", repositoryId: "repo123", }; const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing branches: Repository not found" }], + content: [{ type: "text", text: "Error with branch operation: Repository not found" }], isError: true, }); }); @@ -8240,19 +7304,20 @@ describe("repos tools", () => { describe("repo_list_my_branches_by_repo error handling", () => { it("should handle my branches listing errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_my_branches_by_repo); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); const [, , , handler] = call; mockGitApi.getRefs.mockRejectedValue(new Error("Access denied")); const params = { + action: "list_mine", repositoryId: "repo123", }; const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing my branches: Access denied" }], + content: [{ type: "text", text: "Error with branch operation: Access denied" }], isError: true, }); }); @@ -8261,12 +7326,13 @@ describe("repos tools", () => { describe("repo_get_repo_by_name_or_id error handling", () => { it("should handle repository fetch errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_repo_by_name_or_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_repository); const [, , , handler] = call; mockGitApi.getRepositories.mockRejectedValue(new Error("Project not accessible")); const params = { + action: "get", project: "test-project", repositoryNameOrId: "test-repo", }; @@ -8274,7 +7340,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error getting repository: Project not accessible" }], + content: [{ type: "text", text: "Error with repository operation: Project not accessible" }], isError: true, }); }); @@ -8283,12 +7349,13 @@ describe("repos tools", () => { describe("repo_get_branch_by_name error handling", () => { it("should handle branch fetch errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_branch_by_name); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_branch); const [, , , handler] = call; mockGitApi.getRefs.mockRejectedValue(new Error("Branch access denied")); const params = { + action: "get", repositoryId: "repo123", branchName: "main", }; @@ -8296,7 +7363,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error getting branch: Branch access denied" }], + content: [{ type: "text", text: "Error with branch operation: Branch access denied" }], isError: true, }); }); @@ -8305,12 +7372,13 @@ describe("repos tools", () => { describe("repo_get_pull_request_by_id error handling", () => { it("should handle pull request fetch errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_by_id); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; mockGitApi.getPullRequest.mockRejectedValue(new Error("Pull request not found")); const params = { + action: "get", repositoryId: "repo123", pullRequestId: 456, }; @@ -8318,7 +7386,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error getting pull request: Pull request not found" }], + content: [{ type: "text", text: "Error with pull request operation: Pull request not found" }], isError: true, }); }); @@ -8327,12 +7395,13 @@ describe("repos tools", () => { describe("repo_reply_to_comment error handling", () => { it("should handle comment creation errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.reply_to_comment); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; mockGitApi.createComment.mockRejectedValue(new Error("Thread is locked")); const params = { + action: "reply", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -8342,7 +7411,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error replying to comment: Thread is locked" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Thread is locked" }], isError: true, }); }); @@ -8351,12 +7420,13 @@ describe("repos tools", () => { describe("repo_create_pull_request_thread error handling", () => { it("should handle thread creation errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; mockGitApi.createThread.mockRejectedValue(new Error("Invalid file path")); const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -8365,17 +7435,18 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error creating pull request thread: Invalid file path" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Invalid file path" }], isError: true, }); }); it("should handle validation errors for line numbers", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request_thread); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; const params = { + action: "create", repositoryId: "repo123", pullRequestId: 456, content: "Test comment", @@ -8394,12 +7465,13 @@ describe("repos tools", () => { describe("repo_update_pull_request_thread error handling", () => { it("should handle thread update errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; mockGitApi.updateThread.mockRejectedValue(new Error("Thread not found")); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -8410,19 +7482,20 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request thread: Thread not found" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Thread not found" }], isError: true, }); }); it("should handle API connection errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; mockGitApi.updateThread.mockRejectedValue(new Error("Network connection failed")); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -8433,19 +7506,20 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request thread: Network connection failed" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Network connection failed" }], isError: true, }); }); it("should handle non-Error thrown objects", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request_thread); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_thread_write); const [, , , handler] = call; mockGitApi.updateThread.mockRejectedValue("String error"); const params = { + action: "update_status", repositoryId: "repo123", pullRequestId: 456, threadId: 789, @@ -8456,7 +7530,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error updating pull request thread: Unknown error occurred" }], + content: [{ type: "text", text: "Error with pull request thread write operation: Unknown error occurred" }], isError: true, }); }); @@ -8465,7 +7539,7 @@ describe("repos tools", () => { describe("repo_search_commits error handling", () => { it("should handle commit search errors (non-ok HTTP response)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.search_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_search_commits); const [, , , handler] = call; tokenProvider.mockResolvedValue("fake-token"); @@ -8478,12 +7552,13 @@ describe("repos tools", () => { describe("repo_list_pull_requests_by_commits error handling", () => { it("should handle pull request query errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_pull_requests_by_commits); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request); const [, , , handler] = call; mockGitApi.getPullRequestQuery.mockRejectedValue(new Error("Invalid commit ID")); const params = { + action: "list_by_commits", project: "test-project", repository: "test-repo", commits: ["abc123"], @@ -8492,7 +7567,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error querying pull requests by commits: Invalid commit ID" }], + content: [{ type: "text", text: "Error with pull request operation: Invalid commit ID" }], isError: true, }); }); @@ -8501,7 +7576,7 @@ describe("repos tools", () => { describe("repo_list_directory", () => { it("should list directory with default options", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; const items = [ @@ -8523,7 +7598,7 @@ describe("repos tools", () => { mockGitApi.getItems.mockResolvedValue(items); - const result = await handler({ repositoryId: "repo123", path: "/", recursive: false, recursionDepth: 1 }); + const result = await handler({ action: "list_directory", repositoryId: "repo123", path: "/", recursive: false, recursionDepth: 1 }); expect(mockGitApi.getItems).toHaveBeenCalledWith("repo123", undefined, "/", VersionControlRecursionType.OneLevel, true, false, false, false, undefined); @@ -8558,7 +7633,7 @@ describe("repos tools", () => { it("should recursively list and filter by recursion depth", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; const items = [ @@ -8572,6 +7647,7 @@ describe("repos tools", () => { mockGitApi.getItems.mockResolvedValue(items); const result = await handler({ + action: "list_directory", repositoryId: "repo123", path: "/src", recursive: true, @@ -8607,12 +7683,12 @@ describe("repos tools", () => { it("should return isError when no items found (empty array)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; mockGitApi.getItems.mockResolvedValue([]); - const result = await handler({ repositoryId: "repo123", path: "/missing" }); + const result = await handler({ action: "list_directory", repositoryId: "repo123", path: "/missing" }); expect(result).toEqual({ content: [{ type: "text", text: "No items found at path: /missing. The path may not exist in the repository." }], @@ -8622,7 +7698,7 @@ describe("repos tools", () => { it("should succeed for empty directory (folder entry only)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; const items = [ @@ -8635,7 +7711,7 @@ describe("repos tools", () => { ]; mockGitApi.getItems.mockResolvedValue(items); - const result = await handler({ repositoryId: "repo123", path: "/empty-dir" }); + const result = await handler({ action: "list_directory", repositoryId: "repo123", path: "/empty-dir" }); expect(result.isError).toBeFalsy(); expect(result.content[0].text).toContain('"count": 1'); @@ -8643,12 +7719,12 @@ describe("repos tools", () => { it("should return isError when getItems returns null", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; mockGitApi.getItems.mockResolvedValue(null); - const result = await handler({ repositoryId: "repo123", path: "/nonexistent" }); + const result = await handler({ action: "list_directory", repositoryId: "repo123", path: "/nonexistent" }); expect(result).toEqual({ content: [{ type: "text", text: "No items found at path: /nonexistent. The path may not exist in the repository." }], @@ -8660,15 +7736,15 @@ describe("repos tools", () => { describe("repo_list_directory error handling", () => { it("should handle directory list errors", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); const [, , , handler] = call; mockGitApi.getItems.mockRejectedValue(new Error("Repository access denied")); - const result = await handler({ repositoryId: "repo123", path: "/" }); + const result = await handler({ action: "list_directory", repositoryId: "repo123", path: "/" }); expect(result).toEqual({ - content: [{ type: "text", text: "Error listing directory: Repository access denied" }], + content: [{ type: "text", text: "Error with file operation: Repository access denied" }], isError: true, }); }); @@ -8677,12 +7753,13 @@ describe("repos tools", () => { describe("Non-Error objects handling", () => { it("should handle non-Error thrown objects", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_pull_request_write); const [, , , handler] = call; mockGitApi.createPullRequest.mockRejectedValue("String error"); const params = { + action: "create", repositoryId: "repo123", sourceRefName: "refs/heads/feature", targetRefName: "refs/heads/main", @@ -8692,7 +7769,7 @@ describe("repos tools", () => { const result = await handler(params); expect(result).toEqual({ - content: [{ type: "text", text: "Error creating pull request: Unknown error occurred" }], + content: [{ type: "text", text: "Error with pull request write operation: Unknown error occurred" }], isError: true, }); }); @@ -8702,8 +7779,8 @@ describe("repos tools", () => { it("returns file content on success", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_file_content); - if (!call) throw new Error("repo_get_file_content tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); + if (!call) throw new Error("repo_file tool not registered"); const [, , , handler] = call; const fileContent = "# Hello World\nThis is a test file."; @@ -8715,6 +7792,7 @@ describe("repos tools", () => { mockGitApi.getItemText.mockResolvedValue(contentStream); const result = await handler({ + action: "get_content", repositoryId: "test-repo", path: "README.md", project: "test-project", @@ -8727,13 +7805,14 @@ describe("repos tools", () => { it("returns isError: true when getItemText throws", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_file_content); - if (!call) throw new Error("repo_get_file_content tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); + if (!call) throw new Error("repo_file tool not registered"); const [, , , handler] = call; mockGitApi.getItemText.mockRejectedValue(new Error("Network error")); const result = await handler({ + action: "get_content", repositoryId: "test-repo", path: "README.md", project: "test-project", @@ -8746,8 +7825,8 @@ describe("repos tools", () => { it("returns isError: true when getItemText stream contains ADO error JSON (e.g. file not found)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_file_content); - if (!call) throw new Error("repo_get_file_content tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.repo_file); + if (!call) throw new Error("repo_file tool not registered"); const [, , , handler] = call; const adoErrorBody = JSON.stringify({ @@ -8768,6 +7847,7 @@ describe("repos tools", () => { mockGitApi.getItemText.mockResolvedValue(errorStream); const params = { + action: "get_content", repositoryId: "test-repo", path: "nonexistent.md", project: "test-project", @@ -8780,4 +7860,633 @@ describe("repos tools", () => { }); }); }); + + describe("unknown action fallbacks", () => { + function getHandler(toolName: string) { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === toolName); + if (!call) throw new Error(`${toolName} tool not registered`); + return call[3] as (...args: unknown[]) => Promise; + } + + it("repo_repository: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_repository); + const result = await (handler as any)({ action: "invalid_action", project: "proj" }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_pull_request: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_pull_request); + const result = await (handler as any)({ action: "invalid_action" }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_pull_request: list with no repositoryId or project returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_pull_request); + const result = await (handler as any)({ action: "list", status: "Active", created_by_me: false, i_am_reviewer: false }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Either repositoryId or project must be provided."); + }); + + it("repo_pull_request_thread: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_pull_request_thread); + const result = await (handler as any)({ action: "invalid_action", repositoryId: "repo1", pullRequestId: 1 }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_branch: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_branch); + const result = await (handler as any)({ action: "invalid_action", repositoryId: "repo1" }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_file: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_file); + const result = await (handler as any)({ action: "invalid_action", repositoryId: "repo1" }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_pull_request_write: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_pull_request_write); + const result = await (handler as any)({ action: "invalid_action", repositoryId: "repo1" }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_pull_request_thread_write: unknown action returns error", async () => { + const handler = getHandler(REPO_TOOLS.repo_pull_request_thread_write); + const result = await (handler as any)({ action: "invalid_action", repositoryId: "repo1", pullRequestId: 1 }); + expect((result as any).isError).toBe(true); + expect((result as any).content[0].text).toContain("Unknown action"); + }); + + it("repo_pull_request_write update: returns message when updatePullRequest returns null (covers trimPullRequest null path)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("repo_pull_request_write tool not registered"); + const handler = call[3]; + + mockGitApi.updatePullRequest.mockResolvedValue(null); + + const result = await (handler as any)({ + action: "update", + repositoryId: "repo123", + pullRequestId: 123, + project: "test-project", + title: "New Title", + }); + + expect((result as any).content[0].text).toBe("Pull request updated but API returned no data."); + }); + }); + + describe("required parameter validation", () => { + function getHandler(toolName: string) { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === toolName); + if (!call) throw new Error(`${toolName} not registered`); + return call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + } + + // repo_repository + it("repo_repository get: missing project", async () => { + const h = getHandler(REPO_TOOLS.repo_repository); + const r = await h({ action: "get", repositoryNameOrId: "repo1" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("project is required"); + }); + it("repo_repository get: missing repositoryNameOrId", async () => { + const h = getHandler(REPO_TOOLS.repo_repository); + const r = await h({ action: "get", project: "proj" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryNameOrId is required"); + }); + it("repo_repository list: missing project", async () => { + const h = getHandler(REPO_TOOLS.repo_repository); + const r = await h({ action: "list" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("project is required"); + }); + + // repo_pull_request + it("repo_pull_request get: missing repositoryId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request); + const r = await h({ action: "get", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryId is required"); + }); + it("repo_pull_request get: missing pullRequestId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request); + const r = await h({ action: "get", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("pullRequestId is required"); + }); + it("repo_pull_request list_by_commits: missing project", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request); + const r = await h({ action: "list_by_commits", repository: "r", commits: ["c"] }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("project is required"); + }); + it("repo_pull_request list_by_commits: missing repository", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request); + const r = await h({ action: "list_by_commits", project: "p", commits: ["c"] }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repository is required"); + }); + it("repo_pull_request list_by_commits: missing commits", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request); + const r = await h({ action: "list_by_commits", project: "p", repository: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("commits is required"); + }); + + // repo_pull_request_thread + it("repo_pull_request_thread list_comments: missing threadId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_thread); + const r = await h({ action: "list_comments", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("threadId is required"); + }); + + // repo_branch + it("repo_branch get: missing branchName", async () => { + const h = getHandler(REPO_TOOLS.repo_branch); + const r = await h({ action: "get", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("branchName is required"); + }); + + // repo_file + it("repo_file get_content: missing path", async () => { + const h = getHandler(REPO_TOOLS.repo_file); + const r = await h({ action: "get_content", repositoryId: "r", path: "" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("path is required"); + }); + + // repo_pull_request_write create + it("repo_pull_request_write create: missing repositoryId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "create" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryId is required"); + }); + it("repo_pull_request_write create: missing sourceRefName", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "create", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("sourceRefName is required"); + }); + it("repo_pull_request_write create: missing targetRefName", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "create", repositoryId: "r", sourceRefName: "src" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("targetRefName is required"); + }); + it("repo_pull_request_write create: missing title", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "create", repositoryId: "r", sourceRefName: "src", targetRefName: "tgt" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("title is required"); + }); + + // repo_pull_request_write update + it("repo_pull_request_write update: missing repositoryId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryId is required"); + }); + it("repo_pull_request_write update: missing pullRequestId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("pullRequestId is required"); + }); + + // repo_pull_request_write update_reviewers + it("repo_pull_request_write update_reviewers: missing repositoryId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update_reviewers" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryId is required"); + }); + it("repo_pull_request_write update_reviewers: missing pullRequestId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update_reviewers", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("pullRequestId is required"); + }); + it("repo_pull_request_write update_reviewers: missing reviewerIds", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update_reviewers", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("reviewerIds is required"); + }); + it("repo_pull_request_write update_reviewers: missing reviewerAction", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "update_reviewers", repositoryId: "r", pullRequestId: 1, reviewerIds: ["id1"] }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("reviewerAction is required"); + }); + + // repo_pull_request_write vote + it("repo_pull_request_write vote: missing repositoryId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "vote" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("repositoryId is required"); + }); + it("repo_pull_request_write vote: missing pullRequestId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "vote", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("pullRequestId is required"); + }); + it("repo_pull_request_write vote: missing vote", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_write); + const r = await h({ action: "vote", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("vote is required"); + }); + + // repo_pull_request_thread_write + it("repo_pull_request_thread_write create: missing content", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_thread_write); + const r = await h({ action: "create", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("content is required"); + }); + it("repo_pull_request_thread_write reply: missing threadId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_thread_write); + const r = await h({ action: "reply", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("threadId is required"); + }); + it("repo_pull_request_thread_write reply: missing content", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_thread_write); + const r = await h({ action: "reply", repositoryId: "r", pullRequestId: 1, threadId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("content is required"); + }); + it("repo_pull_request_thread_write update_status: missing threadId", async () => { + const h = getHandler(REPO_TOOLS.repo_pull_request_thread_write); + const r = await h({ action: "update_status", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("threadId is required"); + }); + }); + + describe("non-Error catch branches", () => { + function getHandler(toolName: string) { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === toolName); + if (!call) throw new Error(`${toolName} not registered`); + return call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + } + + it("repo_repository: non-Error thrown returns Unknown error occurred", async () => { + mockGitApi.getRepositories.mockRejectedValue("not an error object"); + const h = getHandler(REPO_TOOLS.repo_repository); + const r = await h({ action: "list", project: "p" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Unknown error occurred"); + }); + + it("repo_pull_request_thread: non-Error thrown returns Unknown error occurred", async () => { + mockGitApi.getThreads.mockRejectedValue("not an error object"); + const h = getHandler(REPO_TOOLS.repo_pull_request_thread); + const r = await h({ action: "list", repositoryId: "r", pullRequestId: 1 }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Unknown error occurred"); + }); + + it("repo_branch: non-Error thrown returns Unknown error occurred", async () => { + mockGitApi.getRefs.mockRejectedValue("not an error object"); + const h = getHandler(REPO_TOOLS.repo_branch); + const r = await h({ action: "list", repositoryId: "r" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Unknown error occurred"); + }); + + it("repo_file: non-Error thrown returns Unknown error occurred", async () => { + mockGitApi.getItems.mockRejectedValue("not an error object"); + const h = getHandler(REPO_TOOLS.repo_file); + const r = await h({ action: "list_directory", repositoryId: "r", path: "/" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Unknown error occurred"); + }); + + it("repo_create_branch: non-Error thrown in outer catch", async () => { + // Simulate a non-Error thrown in the outer try (e.g. connectionProvider itself throws) + const failingConnection = jest.fn().mockRejectedValue("connection string error"); + configureRepoTools(server, tokenProvider, failingConnection, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("repo_create_branch not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new-branch" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Unknown error occurred"); + }); + }); + + describe("feature branch coverage", () => { + // repo_pull_request: includeChangedFiles edge cases + describe("repo_pull_request get includeChangedFiles edge cases", () => { + function getHandler() { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("not registered"); + return call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + } + + it("empty iterations returns changedFilesSummary with empty entries", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1 }); + mockGitApi.getPullRequestIterations.mockResolvedValue([]); + const h = getHandler(); + const r = await h({ action: "get", repositoryId: "r", pullRequestId: 1, includeChangedFiles: true }); + const data = JSON.parse(r.content[0].text); + expect(data.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 }); + }); + + it("iteration with null id returns empty changedFilesSummary", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1 }); + mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: null }]); + const h = getHandler(); + const r = await h({ action: "get", repositoryId: "r", pullRequestId: 1, includeChangedFiles: true }); + const data = JSON.parse(r.content[0].text); + expect(data.changedFilesSummary).toEqual({ changeEntries: [], fileCount: 0 }); + }); + + it("getPullRequestIterations throws falls back to empty changedFilesSummary", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1 }); + mockGitApi.getPullRequestIterations.mockRejectedValue(new Error("iterations failed")); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + const h = getHandler(); + const r = await h({ action: "get", repositoryId: "r", pullRequestId: 1, includeChangedFiles: true }); + const data = JSON.parse(r.content[0].text); + expect(data.changedFilesSummary).toEqual({}); + consoleSpy.mockRestore(); + }); + + it("getPullRequestLabels throws non-Error falls back to empty labelSummary", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1, repository: { project: { id: "pid", name: "proj" } } }); + mockGitApi.getPullRequestLabels.mockRejectedValue("label fetch failed"); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + const h = getHandler(); + const r = await h({ action: "get", repositoryId: "r", pullRequestId: 1, includeLabels: true }); + const data = JSON.parse(r.content[0].text); + expect(data.labelSummary).toEqual({}); + consoleSpy.mockRestore(); + }); + }); + + // repo_pull_request list: user_is_reviewer non-Error + it("repo_pull_request list: getUserIdFromEmail throws non-Error for user_is_reviewer", async () => { + mockGetUserIdFromEmail.mockRejectedValue("not an error"); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ action: "list", repositoryId: "r", user_is_reviewer: "user@example.com", status: "Active", created_by_me: false, i_am_reviewer: false }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Error finding reviewer"); + }); + + // repo_file: get_content without version (versionDescriptor = undefined) + it("repo_file get_content: no version yields undefined versionDescriptor", async () => { + const { Readable } = await import("stream"); + const stream = new Readable(); + stream.push("file content"); + stream.push(null); + mockGitApi.getItemText.mockResolvedValue(stream); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "get_content", repositoryId: "r", path: "/file.ts" }); + expect(r.content[0].text).toBe("file content"); + expect(mockGitApi.getItemText).toHaveBeenCalledWith("r", "/file.ts", undefined, undefined, undefined, undefined, undefined, false, undefined, true); + }); + + // repo_file: list_directory with version string + it("repo_file list_directory: with version uses buildVersionDescriptor", async () => { + mockGitApi.getItems.mockResolvedValue([{ path: "/README.md", isFolder: false }]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/", version: "main", versionType: "Branch" }); + const data = JSON.parse(r.content[0].text); + expect(data.count).toBe(1); + }); + + // repo_file: list_directory recursive filter with path="/" and item without path + it("repo_file list_directory: recursive with path='/' and item missing path filtered out", async () => { + mockGitApi.getItems.mockResolvedValue([ + { path: "/src", isFolder: true }, + { path: "/src/main.ts", isFolder: false }, + { path: null, isFolder: false }, // item without path — should be filtered + ]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/", recursive: true, recursionDepth: 1 }); + const data = JSON.parse(r.content[0].text); + // /src (depth 1) and /src/main.ts (depth 2 > baseDepth 0 + 1) — main.ts should be filtered + expect(data.items.every((i: { path: unknown }) => i.path !== null)).toBe(true); + }); + + // repo_pull_request_write update: label deletion skips label with no id + it("repo_pull_request_write update: skips label deletion when label has no id", async () => { + mockGitApi.getPullRequestLabels.mockResolvedValue([{ name: "noid-label" }]); // no .id + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1, repository: { name: "r" } }); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request_write); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + await handler({ action: "update", repositoryId: "r", pullRequestId: 1, labels: [] }); + expect(mockGitApi.deletePullRequestLabels).not.toHaveBeenCalled(); + }); + + // repo_create_branch: getRefs throws (inner catch) + it("repo_create_branch: getRefs throws returns error", async () => { + mockGitApi.getRefs.mockRejectedValue(new Error("refs failed")); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new", sourceBranchName: "main" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Error retrieving source branch"); + }); + + // repo_create_branch: updateRefs returns non-success result + it("repo_create_branch: updateRefs returns failure result", async () => { + mockGitApi.getRefs.mockResolvedValue([{ name: "refs/heads/main", objectId: "abc123" }]); + mockGitApi.updateRefs.mockResolvedValue([{ success: false, customMessage: "Branch already exists" }]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new", sourceBranchName: "main" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Branch already exists"); + }); + + // repo_create_branch: updateRefs itself throws (inner try/catch) + it("repo_create_branch: updateRefs throws returns error", async () => { + mockGitApi.getRefs.mockResolvedValue([{ name: "refs/heads/main", objectId: "abc123" }]); + mockGitApi.updateRefs.mockRejectedValue(new Error("refs update failed")); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new", sourceBranchName: "main" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Error creating branch 'new': refs update failed"); + }); + + // buildVersionDescriptor: versionType || "Branch" fallback + it("repo_file list_directory: versionType undefined falls back to Branch", async () => { + mockGitApi.getItems.mockResolvedValue([{ path: "/file.ts", isFolder: false }]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + // Pass version but no versionType — triggers the "Branch" fallback inside buildVersionDescriptor + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/", version: "main" }); + const data = JSON.parse(r.content[0].text); + expect(data.count).toBe(1); + }); + + // line 136: ?? GitVersionType.Branch fallback when versionType not in map + it("repo_file list_directory: unknown versionType triggers ?? GitVersionType.Branch fallback", async () => { + mockGitApi.getItems.mockResolvedValue([{ path: "/file.ts", isFolder: false }]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/", version: "main", versionType: "Unknown" }); + const data = JSON.parse(r.content[0].text); + expect(data.count).toBe(1); + }); + + // line 563: version truthy branch in get_content + it("repo_file get_content: with version creates versionDescriptor", async () => { + const { Readable } = await import("stream"); + const stream = new Readable(); + stream.push("content"); + stream.push(null); + mockGitApi.getItemText.mockResolvedValue(stream); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "get_content", repositoryId: "r", path: "/file.ts", version: "abc123", versionType: "Commit" }); + expect(r.content[0].text).toBe("content"); + expect(mockGitApi.getItemText).toHaveBeenCalledWith( + "r", + "/file.ts", + undefined, + undefined, + undefined, + undefined, + undefined, + false, + { version: "abc123", versionType: GitVersionType.Commit }, + true + ); + }); + + // line 579: versionType === "Commit" TRUE branch in list_directory + it("repo_file list_directory: versionType Commit is remapped to Branch for buildVersionDescriptor", async () => { + mockGitApi.getItems.mockResolvedValue([{ path: "/src", isFolder: true }]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/", version: "main", versionType: "Commit" }); + const data = JSON.parse(r.content[0].text); + expect(data.count).toBe(1); + }); + + // line 593: path !== "/" branch in recursive filter (non-root path) + it("repo_file list_directory: recursive with non-root path filters by depth correctly", async () => { + mockGitApi.getItems.mockResolvedValue([ + { path: "/src", isFolder: true }, + { path: "/src/utils", isFolder: true }, + { path: "/src/utils/helper.ts", isFolder: false }, + ]); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_file); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + // path="/src" base depth=1, recursionDepth=1 → only items at depth ≤ 2 + const r = await handler({ action: "list_directory", repositoryId: "r", path: "/src", recursive: true, recursionDepth: 1 }); + const data = JSON.parse(r.content[0].text); + // /src (depth 1) and /src/utils (depth 2) qualify; /src/utils/helper.ts (depth 3) does not + expect(data.items.map((i: { path: unknown }) => i.path)).not.toContain("/src/utils/helper.ts"); + }); + + // lines 295-296: ?? [] and ?? 0 fallbacks when changes.changeEntries is undefined + it("repo_pull_request get: includeChangedFiles with undefined changeEntries falls back to []", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1 }); + mockGitApi.getPullRequestIterations.mockResolvedValue([{ id: 1 }]); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue({ changeEntries: undefined }); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + const r = await handler({ action: "get", repositoryId: "r", pullRequestId: 1, includeChangedFiles: true }); + const data = JSON.parse(r.content[0].text); + expect(data.changedFilesSummary.changeEntries).toEqual([]); + expect(data.changedFilesSummary.fileCount).toBe(0); + }); + + // line 308: non-Error in changedFiles catch + it("repo_pull_request get: includeChangedFiles catch with non-Error logs Unknown error", async () => { + mockGitApi.getPullRequest.mockResolvedValue({ pullRequestId: 1 }); + mockGitApi.getPullRequestIterations.mockRejectedValue("string error not an Error"); + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_pull_request); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }] }>; + await handler({ action: "get", repositoryId: "r", pullRequestId: 1, includeChangedFiles: true }); + expect(consoleSpy).toHaveBeenCalledWith("Error fetching PR changed files: Unknown error"); + consoleSpy.mockRestore(); + }); + + // line 1081: non-Error in getRefs inner catch (repo_create_branch) + it("repo_create_branch: getRefs throws non-Error returns String(error)", async () => { + mockGitApi.getRefs.mockRejectedValue("string error"); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new", sourceBranchName: "main" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Error retrieving source branch"); + }); + + // line 1101: non-Error in updateRefs inner catch (repo_create_branch) + it("repo_create_branch: updateRefs throws non-Error returns String(error)", async () => { + mockGitApi.getRefs.mockResolvedValue([{ name: "refs/heads/main", objectId: "abc123" }]); + mockGitApi.updateRefs.mockRejectedValue("string error"); + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([name]) => name === REPO_TOOLS.repo_create_branch); + if (!call) throw new Error("not registered"); + const handler = call[3] as (p: unknown) => Promise<{ content: [{ text: string }]; isError?: boolean }>; + const r = await handler({ repositoryId: "r", branchName: "new", sourceBranchName: "main" }); + expect(r.isError).toBe(true); + expect(r.content[0].text).toContain("Error creating branch 'new'"); + }); + }); }); diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index e5a400e9..6bddd0fc 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -68,17 +68,7 @@ describe("configureTestPlanTools", () => { it("registers test plan tools on the server", () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); expect((server.tool as jest.Mock).mock.calls.map((call) => call[0])).toEqual( - expect.arrayContaining([ - "testplan_list_test_plans", - "testplan_create_test_plan", - "testplan_create_test_suite", - "testplan_add_test_cases_to_suite", - "testplan_create_test_case", - "testplan_update_test_case_steps", - "testplan_list_test_cases", - "testplan_show_test_results_from_build_id", - "testplan_list_test_suites", - ]) + expect.arrayContaining(["testplan", "testplan_show_test_results_from_build_id", "testplan_test_plan_write", "testplan_test_suite_write", "testplan_test_case_write"]) ); }); }); @@ -101,12 +91,13 @@ describe("configureTestPlanTools", () => { it("should fetch test plans and return the expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_plans"); - if (!call) throw new Error("testplan_list_test_plans tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchPlansResponse([{ id: 1, name: "Test Plan 1" }]); const params = { + action: "list_plans" as const, project: "proj1", filterActivePlans: true, includePlanDetails: false, @@ -121,13 +112,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when listing test plans", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_plans"); - if (!call) throw new Error("testplan_list_test_plans tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; (global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error("API Error")); const params = { + action: "list_plans" as const, project: "proj1", filterActivePlans: true, includePlanDetails: false, @@ -141,13 +133,13 @@ describe("configureTestPlanTools", () => { it("should pass continuation token in URL when provided", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_plans"); - if (!call) throw new Error("testplan_list_test_plans tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchPlansResponse([{ id: 1, name: "Test Plan 1" }], "nextPageToken"); - const result = await handler({ project: "proj1", filterActivePlans: true, includePlanDetails: false, continuationToken: "token123" }); + const result = await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: true, includePlanDetails: false, continuationToken: "token123" }); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("continuationToken=token123"), expect.anything()); const parsed = JSON.parse(result.content[0].text); @@ -156,17 +148,98 @@ describe("configureTestPlanTools", () => { it("should handle non-ok response with status and error text", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_plans"); - if (!call) throw new Error("testplan_list_test_plans tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchPlansResponse([], undefined, false, 404, "Resource not found"); - const result = await handler({ project: "proj1", filterActivePlans: true, includePlanDetails: false }); + const result = await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: true, includePlanDetails: false }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Failed to list test plans (404)"); expect(result.content[0].text).toContain("Resource not found"); }); + + it("should not set User-Agent header when userAgentProvider is omitted", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: [] }), + headers: { get: () => null }, + }); + + await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: true, includePlanDetails: false }); + + expect(global.fetch).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ headers: expect.not.objectContaining({ "User-Agent": expect.anything() }) })); + }); + + it("should not append filterActivePlans when false", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: [] }), + headers: { get: () => null }, + }); + + await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: false, includePlanDetails: false }); + + expect(global.fetch).toHaveBeenCalledWith(expect.not.stringContaining("filterActivePlans"), expect.anything()); + }); + + it("should append includePlanDetails when true", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: [] }), + headers: { get: () => null }, + }); + + await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: false, includePlanDetails: true }); + + expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("includePlanDetails=true"), expect.anything()); + }); + + it("should return empty testPlans array when body.value is null", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: null }), + headers: { get: () => null }, + }); + + const result = await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: false, includePlanDetails: false }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.testPlans).toEqual([]); + }); + + it("should handle non-Error throws and return fallback message", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockRejectedValue("plain string error"); + + const result = await handler({ action: "list_plans" as const, project: "proj1", filterActivePlans: false, includePlanDetails: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); }); describe("list_test_suites tool", () => { @@ -187,8 +260,8 @@ describe("configureTestPlanTools", () => { it("should fetch test suites and return properly nested hierarchy", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([ @@ -221,6 +294,7 @@ describe("configureTestPlanTools", () => { ]); const params = { + action: "list_suites" as const, project: "proj1", planId: 1, }; @@ -253,13 +327,14 @@ describe("configureTestPlanTools", () => { it("should handle test suite with no children", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([{ id: 200, name: "Single Suite", hasChildren: false }]); const params = { + action: "list_suites" as const, project: "proj1", planId: 2, }; @@ -272,13 +347,14 @@ describe("configureTestPlanTools", () => { it("should handle empty test suite list", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([]); const params = { + action: "list_suites" as const, project: "proj1", planId: 3, }; @@ -290,8 +366,8 @@ describe("configureTestPlanTools", () => { it("should handle deeply nested suite hierarchy", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([ @@ -323,6 +399,7 @@ describe("configureTestPlanTools", () => { ]); const params = { + action: "list_suites" as const, project: "proj1", planId: 4, }; @@ -355,13 +432,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when listing test suites", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; (global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error("API Error")); const params = { + action: "list_suites" as const, project: "proj1", planId: 5, }; @@ -373,13 +451,14 @@ describe("configureTestPlanTools", () => { it("should pass continuation token in URL when provided", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([{ id: 400, name: "Suite with Token" }], "nextSuiteToken"); const params = { + action: "list_suites" as const, project: "proj1", planId: 6, continuationToken: "token123", @@ -393,8 +472,8 @@ describe("configureTestPlanTools", () => { it("should not include empty children arrays in output", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([ @@ -413,6 +492,7 @@ describe("configureTestPlanTools", () => { ]); const params = { + action: "list_suites" as const, project: "proj1", planId: 7, }; @@ -425,28 +505,57 @@ describe("configureTestPlanTools", () => { it("should handle non-ok response with status and error text", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_suites"); - if (!call) throw new Error("testplan_list_test_suites tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchSuitesResponse([], undefined, false, 404, "Suite not found"); - const result = await handler({ project: "proj1", planId: 1 }); + const result = await handler({ action: "list_suites" as const, project: "proj1", planId: 1 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Failed to list test suites (404)"); expect(result.content[0].text).toContain("Suite not found"); }); + + it("should return error when planId is missing for list_suites", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "list_suites" as const, project: "proj1" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("planId is required for list_suites"); + }); + + it("should return empty testSuites array when body.value is null", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: null }), + headers: { get: () => null }, + }); + + const result = await handler({ action: "list_suites" as const, project: "proj1", planId: 1 }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.testSuites).toEqual([]); + }); }); describe("create_test_plan tool", () => { it("should call createTestPlan with the correct parameters and return the expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_plan"); - if (!call) throw new Error("testplan_create_test_plan tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_plan_write"); + if (!call) throw new Error("testplan_test_plan_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestPlan as jest.Mock).mockResolvedValue({ id: 1, name: "New Test Plan" }); const params = { + action: "create" as const, project: "proj1", name: "New Test Plan", iteration: "Iteration 1", @@ -473,13 +582,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when creating test plan", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_plan"); - if (!call) throw new Error("testplan_create_test_plan tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_plan_write"); + if (!call) throw new Error("testplan_test_plan_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestPlan as jest.Mock).mockRejectedValue(new Error("API Error")); const params = { + action: "create" as const, project: "proj1", name: "Failed Plan", iteration: "Iteration 1", @@ -490,17 +600,53 @@ describe("configureTestPlanTools", () => { expect(result.content[0].text).toContain("Error creating test plan"); expect(result.content[0].text).toContain("API Error"); }); + + it("should return error when name is missing", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_plan_write"); + if (!call) throw new Error("testplan_test_plan_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ project: "proj1", iteration: "Sprint 1" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("name is required for create"); + }); + + it("should return error when iteration is missing", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_plan_write"); + if (!call) throw new Error("testplan_test_plan_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ project: "proj1", name: "Plan" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("iteration is required for create"); + }); + + it("should handle non-Error throws and return fallback message", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_plan_write"); + if (!call) throw new Error("testplan_test_plan_write tool not registered"); + const [, , , handler] = call; + + (mockTestPlanApi.createTestPlan as jest.Mock).mockRejectedValue("plain string error"); + + const result = await handler({ project: "proj1", name: "Plan", iteration: "Sprint 1" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); }); describe("create_test_suite tool", () => { it("should call createTestSuite with the correct parameters and return the expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); - if (!call) throw new Error("testplan_create_test_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue({ id: 10, name: "New Test Suite" }); const params = { + action: "create" as const, project: "proj1", planId: 1, parentSuiteId: 5, @@ -525,13 +671,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when creating test suite", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); - if (!call) throw new Error("testplan_create_test_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockRejectedValue(new Error("API Error")); const params = { + action: "create" as const, project: "proj1", planId: 1, parentSuiteId: 5, @@ -546,8 +693,8 @@ describe("configureTestPlanTools", () => { it("should create test suite with different parent suite IDs", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); - if (!call) throw new Error("testplan_create_test_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue({ @@ -556,6 +703,7 @@ describe("configureTestPlanTools", () => { parentSuite: { id: 10 }, }); const params = { + action: "create" as const, project: "proj1", planId: 2, parentSuiteId: 10, @@ -590,12 +738,13 @@ describe("configureTestPlanTools", () => { it("should handle empty or null response from createTestSuite", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_suite"); - if (!call) throw new Error("testplan_create_test_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestPlanApi.createTestSuite as jest.Mock).mockResolvedValue(null); const params = { + action: "create" as const, project: "proj1", planId: 1, parentSuiteId: 5, @@ -605,6 +754,111 @@ describe("configureTestPlanTools", () => { expect(result.content[0].text).toBe(JSON.stringify(null, null, 2)); }); + + it("should retry on concurrency error (TF26071) and succeed on second attempt", async () => { + jest.useFakeTimers(); + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + (mockTestPlanApi.createTestSuite as jest.Mock).mockRejectedValueOnce(new Error("TF26071: concurrency conflict")).mockResolvedValueOnce({ id: 10, name: "Retry Suite" }); + + const params = { + action: "create" as const, + project: "proj1", + planId: 1, + parentSuiteId: 5, + name: "Retry Suite", + }; + + const handlerPromise = handler(params); + await jest.runAllTimersAsync(); + const result = await handlerPromise; + + jest.useRealTimers(); + + expect(mockTestPlanApi.createTestSuite).toHaveBeenCalledTimes(2); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe(JSON.stringify({ id: 10, name: "Retry Suite" }, null, 2)); + }); + + it("should retry on 'got update' concurrency error and succeed on third attempt", async () => { + jest.useFakeTimers(); + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + (mockTestPlanApi.createTestSuite as jest.Mock) + .mockRejectedValueOnce(new Error("got update conflict")) + .mockRejectedValueOnce(new Error("changed by someone else")) + .mockResolvedValueOnce({ id: 20, name: "Multi-Retry Suite" }); + + const params = { + action: "create" as const, + project: "proj1", + planId: 2, + parentSuiteId: 10, + name: "Multi-Retry Suite", + }; + + const handlerPromise = handler(params); + await jest.runAllTimersAsync(); + const result = await handlerPromise; + + jest.useRealTimers(); + + expect(mockTestPlanApi.createTestSuite).toHaveBeenCalledTimes(3); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe(JSON.stringify({ id: 20, name: "Multi-Retry Suite" }, null, 2)); + }); + + it("should return error when planId is missing for create", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "create" as const, project: "proj1", parentSuiteId: 5, name: "Suite" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("planId is required for create"); + }); + + it("should return error when parentSuiteId is missing for create", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "create" as const, project: "proj1", planId: 1, name: "Suite" } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("parentSuiteId is required for create"); + }); + + it("should return error when name is missing for create", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "create" as const, project: "proj1", planId: 1, parentSuiteId: 5 } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("name is required for create"); + }); + + it("should handle non-Error throws in inner retry catch", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + (mockTestPlanApi.createTestSuite as jest.Mock).mockRejectedValue("plain string error"); + + const result = await handler({ action: "create" as const, project: "proj1", planId: 1, parentSuiteId: 5, name: "Suite" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); }); describe("list_test_cases tool", () => { @@ -625,13 +879,13 @@ describe("configureTestPlanTools", () => { it("should fetch test cases and return the expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); - if (!call) throw new Error("testplan_list_test_cases tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchResponse([{ id: 1, name: "Test Case 1" }]); - const result = await handler({ project: "proj1", planid: 1, suiteid: 2 }); + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2 }); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("proj1/_apis/testplan/Plans/1/Suites/2/TestCase"), expect.objectContaining({ method: "GET" })); expect(result.content[0].text).toBe(JSON.stringify({ testCases: [{ id: 1, name: "Test Case 1" }] }, null, 2)); @@ -639,13 +893,13 @@ describe("configureTestPlanTools", () => { it("should handle API errors when listing test cases", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); - if (!call) throw new Error("testplan_list_test_cases tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; (global.fetch as jest.Mock) = jest.fn().mockRejectedValue(new Error("API Error")); - const result = await handler({ project: "proj1", planid: 1, suiteid: 2 }); + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error listing test cases"); expect(result.content[0].text).toContain("API Error"); @@ -653,13 +907,13 @@ describe("configureTestPlanTools", () => { it("should pass continuation token when provided", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); - if (!call) throw new Error("testplan_list_test_cases tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchResponse([{ id: 1, name: "Test Case 1" }], "nextToken456"); - const result = await handler({ project: "proj1", planid: 1, suiteid: 2, continuationToken: "token123" }); + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2, continuationToken: "token123" }); expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining("continuationToken=token123"), expect.anything()); const parsed = JSON.parse(result.content[0].text); @@ -669,13 +923,13 @@ describe("configureTestPlanTools", () => { it("should not include continuationToken when API does not return one", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); - if (!call) throw new Error("testplan_list_test_cases tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchResponse([{ id: 1, name: "Test Case 1" }]); - const result = await handler({ project: "proj1", planid: 1, suiteid: 2 }); + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2 }); const parsed = JSON.parse(result.content[0].text); expect(parsed.continuationToken).toBeUndefined(); expect(parsed.testCases).toEqual([{ id: 1, name: "Test Case 1" }]); @@ -683,17 +937,67 @@ describe("configureTestPlanTools", () => { it("should handle non-ok response with status and error text", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_list_test_cases"); - if (!call) throw new Error("testplan_list_test_cases tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); const [, , , handler] = call; mockFetchResponse([], undefined, false, 404, "Test case not found"); - const result = await handler({ project: "proj1", planid: 1, suiteid: 2 }); + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2 }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Failed to list test cases (404)"); expect(result.content[0].text).toContain("Test case not found"); }); + + it("should return error when planId is missing for list_cases", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "list_cases" as const, project: "proj1", suiteId: 2 } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("planId is required for list_cases"); + }); + + it("should return error when suiteId is missing for list_cases", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1 } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("suiteId is required for list_cases"); + }); + + it("should return empty testCases array when body.value is null", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + (global.fetch as jest.Mock) = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ value: null }), + headers: { get: () => null }, + }); + + const result = await handler({ action: "list_cases" as const, project: "proj1", planId: 1, suiteId: 2 }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.testCases).toEqual([]); + }); + + it("should return error for unknown action", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan"); + if (!call) throw new Error("testplan tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "unknown_action" as any, project: "proj1", filterActivePlans: true, includePlanDetails: false }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown action: unknown_action"); + }); }); describe("test_results_from_build_id tool", () => { @@ -951,13 +1255,55 @@ describe("configureTestPlanTools", () => { expect(parsed[2]).toHaveProperty("testCaseTitle"); expect(parsed[2].testCaseTitle).toBe("Another Manual Test Case"); }); + + it("should return empty array when resultsForGroup is null", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); + if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); + const [, , , handler] = call; + + (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ resultsForGroup: null }); + + const result = await handler({ project: "proj1", buildid: 123 }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toEqual([]); + }); + + it("should skip groups that have no results property", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); + if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); + const [, , , handler] = call; + + (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ + resultsForGroup: [{ groupByValue: "NoResults" }, { results: [{ id: 1, testCaseTitle: "Test", outcome: "Passed" }] }], + }); + + const result = await handler({ project: "proj1", buildid: 123 }); + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(1); + expect(parsed[0].testCaseTitle).toBe("Test"); + }); + + it("should handle non-Error throws and return fallback message", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); + if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); + const [, , , handler] = call; + + (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockRejectedValue("plain string error"); + + const result = await handler({ project: "proj1", buildid: 123 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); }); describe("create_test_case tool", () => { it("should create test case with proper parameters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -969,6 +1315,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "New Test Case", steps: "1. Test step 1\n2. Test step 2", @@ -993,8 +1340,8 @@ describe("configureTestPlanTools", () => { it("should create test case & expected result with proper parameters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1006,6 +1353,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "New Test Case", steps: "1. Test step 1 | Expected result 1\n2. Test step 2 | Expected result 2", @@ -1030,8 +1378,8 @@ describe("configureTestPlanTools", () => { it("should handle multiple steps in test case", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1042,6 +1390,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Multi-step Test Case", steps: "1. Step 1\n2. Step 2", @@ -1064,13 +1413,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors in test case creation", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); const params = { + action: "create" as const, project: "proj1", title: "Failed Test Case", steps: "1. Test step", @@ -1084,8 +1434,8 @@ describe("configureTestPlanTools", () => { it("should create test case with all optional parameters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1099,6 +1449,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Full Test Case", steps: "1. Step with & 'quotes' and \"double quotes\"", @@ -1151,8 +1502,8 @@ describe("configureTestPlanTools", () => { it("should handle non-numbered step formats", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1163,6 +1514,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Non-numbered Test Case", steps: "Click the button\nVerify result\n\n3. Numbered step", @@ -1208,8 +1560,8 @@ describe("configureTestPlanTools", () => { it("should handle empty lines in steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1220,6 +1572,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Empty Lines Test Case", steps: "1. First step\n\n\n2. Second step\n \n3. Third step", @@ -1243,8 +1596,8 @@ describe("configureTestPlanTools", () => { it("should create test case without steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1255,6 +1608,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "No Steps Test Case", // no steps parameter @@ -1288,8 +1642,8 @@ describe("configureTestPlanTools", () => { it("should handle edge case XML characters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1300,6 +1654,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Edge Case XML Test", steps: "1. Test with all XML chars: < > & ' \" and some unicode: \u00A0\u2028\u2029", @@ -1333,8 +1688,8 @@ describe("configureTestPlanTools", () => { it("should handle empty string steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1345,6 +1700,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Empty String Steps Test", steps: "", @@ -1367,8 +1723,8 @@ describe("configureTestPlanTools", () => { it("should handle only whitespace steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1379,6 +1735,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Whitespace Steps Test", steps: " \n\t\n ", @@ -1401,8 +1758,8 @@ describe("configureTestPlanTools", () => { it("should handle steps with pipe delimiter for expected results", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1413,6 +1770,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Pipe Delimiter Test", steps: "1. Navigate to login page|Login page loads successfully\n2. Enter username|Username is accepted in field", @@ -1451,8 +1809,8 @@ describe("configureTestPlanTools", () => { it("should handle steps without pipe delimiter using default expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1463,6 +1821,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Default Expected Result Test", steps: "1. Click the button\n2. Navigate to page", @@ -1493,8 +1852,8 @@ describe("configureTestPlanTools", () => { it("should handle mixed steps with and without pipe delimiter", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1505,6 +1864,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Mixed Delimiter Test", steps: "1. Click login button|Login form appears\n2. Enter credentials\n3. Submit form|User is logged in successfully", @@ -1547,8 +1907,8 @@ describe("configureTestPlanTools", () => { it("should handle empty expected result after pipe delimiter", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1559,6 +1919,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Empty Expected Result Test", steps: "1. Perform action|\n2. Another action|", @@ -1589,8 +1950,8 @@ describe("configureTestPlanTools", () => { it("should handle multiple pipe characters in expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1601,6 +1962,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Multiple Pipes Test", steps: "1. Check message|Message shows 'Success | Error | Warning'", @@ -1631,8 +1993,8 @@ describe("configureTestPlanTools", () => { it("should handle whitespace around pipe delimiter", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1643,6 +2005,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Whitespace Pipe Test", steps: "1. Action with spaces | Expected result with spaces \n2. Another action|\n3. Third action|Expected result", @@ -1685,8 +2048,8 @@ describe("configureTestPlanTools", () => { it("should handle special characters in expected results", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1697,6 +2060,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Special Characters Expected Test", steps: "1. Test XML chars|Result contains < > & ' \" characters\n2. Test unicode|Result shows unicode: \u00A0\u2028\u2029", @@ -1735,8 +2099,8 @@ describe("configureTestPlanTools", () => { it("should handle non-numbered steps with pipe delimiter", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1747,6 +2111,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Non-numbered Pipe Test", steps: "Click button|Button is clicked\nVerify result|Result is displayed\nAction without number|Expected without number", @@ -1789,8 +2154,8 @@ describe("configureTestPlanTools", () => { it("should create test case with testsWorkItemId relationship", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1807,6 +2172,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Test Case with Link", steps: "1. Execute test|Test passes", @@ -1855,8 +2221,8 @@ describe("configureTestPlanTools", () => { it("should create test case without testsWorkItemId when not provided", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1867,6 +2233,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Test Case without Link", steps: "1. Execute test|Test passes", @@ -1905,8 +2272,8 @@ describe("configureTestPlanTools", () => { it("should create test case with testsWorkItemId and all other optional parameters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_create_test_case"); - if (!call) throw new Error("testplan_create_test_case tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ @@ -1926,6 +2293,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "create" as const, project: "proj1", title: "Complete Test Case with Link", steps: "1. Execute comprehensive test|All tests pass successfully", @@ -1969,13 +2337,37 @@ describe("configureTestPlanTools", () => { ); expect(result.content[0].text).toContain("Complete Test Case with Link"); }); + + it("should return error when project is missing for create", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "create" as const, title: "Some Test" } as any); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("project is required for create"); + }); + + it("should return error when title is missing for create", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "create" as const, project: "proj1" } as any); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("title is required for create"); + }); }); describe("update_test_case_steps tool", () => { it("should update test case steps with proper parameters", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -1988,6 +2380,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136717, steps: "1. Updated step 1|Expected result 1\n2. Updated step 2|Expected result 2", }; @@ -2012,8 +2405,8 @@ describe("configureTestPlanTools", () => { it("should handle steps with pipe delimiter for expected results", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2025,6 +2418,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136718, steps: "1. Login to application|User is logged in successfully\n2. Navigate to dashboard|Dashboard page loads correctly\n3. Perform action|Action completes as expected", }; @@ -2057,8 +2451,8 @@ describe("configureTestPlanTools", () => { it("should handle steps without pipe delimiter using default expected result", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2070,6 +2464,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136719, steps: "1. Click button\n2. Verify result\n3. Close application", }; @@ -2102,8 +2497,8 @@ describe("configureTestPlanTools", () => { it("should handle XML special characters in steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2115,6 +2510,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136720, steps: "1. Enter text with & 'quotes' and \"double quotes\"|Text is accepted correctly\n2. Submit form|Form submits without errors", }; @@ -2139,8 +2535,8 @@ describe("configureTestPlanTools", () => { it("should handle empty or whitespace-only steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2152,6 +2548,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136721, steps: "1. Valid step\n\n \n2. Another valid step", }; @@ -2176,13 +2573,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when updating test case steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); const params = { + action: "update_steps" as const, id: 136722, steps: "1. Test step that will fail", }; @@ -2195,13 +2593,14 @@ describe("configureTestPlanTools", () => { it("should store HTML tags as XML-escaped formatting in step content", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 200001, rev: 2, fields: {} }); const params = { + action: "update_steps" as const, id: 200001, steps: "1. Click Save button|Button highlights and form submits", }; @@ -2226,13 +2625,14 @@ describe("configureTestPlanTools", () => { it("should convert Markdown bold and italic to XML-escaped HTML", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 200002, rev: 2, fields: {} }); const params = { + action: "update_steps" as const, id: 200002, steps: "1. Press **Submit**|Result shows *success* message", }; @@ -2257,13 +2657,14 @@ describe("configureTestPlanTools", () => { it("should convert Markdown inline code to XML-escaped HTML code tags", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 200003, rev: 2, fields: {} }); const params = { + action: "update_steps" as const, id: 200003, steps: "1. Run `npm install`|Command exits with code `0`", }; @@ -2288,13 +2689,14 @@ describe("configureTestPlanTools", () => { it("should escape non-whitelisted HTML tags", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 200004, rev: 2, fields: {} }); const params = { + action: "update_steps" as const, id: 200004, steps: "1. Inject |Should be escaped", }; @@ -2315,13 +2717,14 @@ describe("configureTestPlanTools", () => { it("should convert Markdown links to XML-escaped anchor tags", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 200005, rev: 2, fields: {} }); const params = { + action: "update_steps" as const, id: 200005, steps: "1. Open [Azure Portal](https://portal.azure.com)|Portal loads", }; @@ -2342,8 +2745,8 @@ describe("configureTestPlanTools", () => { it("should handle mixed numbered and non-numbered steps", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2355,6 +2758,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136723, steps: "1. Numbered step one|Expected result one\nNon-numbered step\n3. Another numbered step|Expected result three", }; @@ -2391,8 +2795,8 @@ describe("configureTestPlanTools", () => { it("should handle multiple pipe characters in expected results", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2404,6 +2808,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136724, steps: "1. Check status message|Message shows 'Success | Warning | Error' status options", }; @@ -2428,8 +2833,8 @@ describe("configureTestPlanTools", () => { it("should handle empty expected results after pipe delimiter", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_update_test_case_steps"); - if (!call) throw new Error("testplan_update_test_case_steps tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); const [, , , handler] = call; (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ @@ -2441,6 +2846,7 @@ describe("configureTestPlanTools", () => { }); const params = { + action: "update_steps" as const, id: 136725, steps: "1. Perform action|\n2. Another action|", }; @@ -2466,18 +2872,69 @@ describe("configureTestPlanTools", () => { ); expect(result.content[0].text).toContain("Empty Expected Results Test Case"); }); + + it("should return error when id is missing for update_steps", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "update_steps" as const, steps: "1. Step one" } as any); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("id is required for update_steps"); + }); + + it("should return error when steps is missing for update_steps", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "update_steps" as const, id: 1 } as any); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("steps is required for update_steps"); + }); + + it("should handle non-Error throws in update_steps and use fallback message", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockRejectedValue("non-Error string thrown"); + + const params = { action: "update_steps" as const, id: 1, steps: "1. step" }; + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); + + it("should return error for unknown action", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_case_write"); + if (!call) throw new Error("testplan_test_case_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "unknown_action" as any }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown action: unknown_action"); + }); }); describe("add_test_cases_to_suite tool", () => { it("should add test cases to suite with array of IDs", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); - if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([{ testCase: { id: 1001 } }, { testCase: { id: 1002 } }]); const params = { + action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2, @@ -2491,13 +2948,14 @@ describe("configureTestPlanTools", () => { it("should add test cases to suite with comma-separated string", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); - if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([{ testCase: { id: 1003 } }, { testCase: { id: 1004 } }]); const params = { + action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2, @@ -2511,13 +2969,14 @@ describe("configureTestPlanTools", () => { it("should handle empty results when adding test cases", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); - if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockResolvedValue([]); const params = { + action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2, @@ -2530,13 +2989,14 @@ describe("configureTestPlanTools", () => { it("should handle API errors when adding test cases to suite", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_add_test_cases_to_suite"); - if (!call) throw new Error("testplan_add_test_cases_to_suite tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); const [, , , handler] = call; (mockTestApi.addTestCasesToSuite as jest.Mock).mockRejectedValue(new Error("API Error")); const params = { + action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2, @@ -2548,5 +3008,64 @@ describe("configureTestPlanTools", () => { expect(result.content[0].text).toContain("Error adding test cases to suite"); expect(result.content[0].text).toContain("API Error"); }); + + it("should handle non-Error throws in add_test_cases and use fallback message", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + (mockTestApi.addTestCasesToSuite as jest.Mock).mockRejectedValue("non-Error string thrown"); + + const params = { action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2, testCaseIds: ["1001"] }; + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown error occurred"); + }); + + it("should return error when planId is missing for add_test_cases", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "add_test_cases" as const, project: "proj1", suiteId: 2, testCaseIds: ["1001"] } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("planId is required for add_test_cases"); + }); + + it("should return error when suiteId is missing for add_test_cases", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "add_test_cases" as const, project: "proj1", planId: 1, testCaseIds: ["1001"] } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("suiteId is required for add_test_cases"); + }); + + it("should return error when testCaseIds is missing for add_test_cases", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "add_test_cases" as const, project: "proj1", planId: 1, suiteId: 2 } as any); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("testCaseIds is required for add_test_cases"); + }); + + it("should return error for unknown action", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_test_suite_write"); + if (!call) throw new Error("testplan_test_suite_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "unknown_action" as any, project: "proj1" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Unknown action: unknown_action"); + }); }); }); diff --git a/test/src/tools/work-items.test.ts b/test/src/tools/work-items.test.ts index 3f95cb90..e01a14c8 100644 --- a/test/src/tools/work-items.test.ts +++ b/test/src/tools/work-items.test.ts @@ -9,6 +9,7 @@ import { Readable } from "stream"; import * as fs from "fs"; import * as path from "path"; import { QueryExpand } from "azure-devops-node-api/interfaces/WorkItemTrackingInterfaces.js"; +import { z } from "zod"; jest.mock("fs"); import { @@ -31,6 +32,8 @@ type ConnectionProviderMock = () => Promise; interface WorkApiMock { getBacklogs: jest.Mock; getBacklogLevelWorkItems: jest.Mock; + reorderBacklogWorkItems: jest.Mock; + reorderIterationWorkItems: jest.Mock; getPredefinedQueryResults: jest.Mock; getTeamIterations: jest.Mock; getIterationWorkItems: jest.Mock; @@ -74,6 +77,8 @@ describe("configureWorkItemTools", () => { mockWorkApi = { getBacklogs: jest.fn(), getBacklogLevelWorkItems: jest.fn(), + reorderBacklogWorkItems: jest.fn(), + reorderIterationWorkItems: jest.fn(), getPredefinedQueryResults: jest.fn(), getTeamIterations: jest.fn(), getIterationWorkItems: jest.fn(), @@ -116,8 +121,8 @@ describe("configureWorkItemTools", () => { it("should call getBacklogs API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); const [, , , handler] = call; (mockWorkApi.getBacklogs as jest.Mock).mockResolvedValue([_mockBacklogs]); @@ -127,7 +132,7 @@ describe("configureWorkItemTools", () => { team: "Fabrikam", }; - const result = await handler(params); + const result = await handler({ action: "list", ...params }); expect(mockWorkApi.getBacklogs).toHaveBeenCalledWith({ project: params.project, @@ -142,8 +147,8 @@ describe("configureWorkItemTools", () => { it("should call getBacklogLevelWorkItems API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); const [, , , handler] = call; (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockResolvedValue([ @@ -173,7 +178,7 @@ describe("configureWorkItemTools", () => { backlogId: "Microsoft.FeatureCategory", }; - const result = await handler(params); + const result = await handler({ action: "list_work_items", ...params }); expect(mockWorkApi.getBacklogLevelWorkItems).toHaveBeenCalledWith({ project: params.project, team: params.team }, params.backlogId); @@ -206,12 +211,122 @@ describe("configureWorkItemTools", () => { }); }); + describe("reorder_backlog_work_items tool", () => { + it("should reorder work items in the team backlog", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); + const [, , , handler] = call; + + const reorderedItems = [ + { id: 101, order: 1 }, + { id: 102, order: 2 }, + ]; + (mockWorkApi.reorderBacklogWorkItems as jest.Mock).mockResolvedValue(reorderedItems); + + const params = { + project: "Contoso", + team: "Fabrikam", + ids: [101, 102], + previousId: 0, + nextId: 103, + parentId: 0, + }; + + const result = await handler({ action: "reorder", ...params }); + + expect(mockWorkApi.reorderBacklogWorkItems).toHaveBeenCalledWith( + { + ids: params.ids, + previousId: params.previousId, + nextId: params.nextId, + parentId: params.parentId, + iterationPath: undefined, + }, + { project: params.project, team: params.team } + ); + expect(mockWorkApi.reorderIterationWorkItems).not.toHaveBeenCalled(); + expect(result.content[0].text).toBe(JSON.stringify(reorderedItems, null, 2)); + }); + + it("should reorder work items in an iteration and pass optional fields", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); + const [, , , handler] = call; + + const reorderedItems = [{ id: 101, order: 4 }]; + (mockWorkApi.reorderIterationWorkItems as jest.Mock).mockResolvedValue(reorderedItems); + + const params = { + project: "Contoso", + team: "Fabrikam", + iterationId: "iteration-1", + ids: [101], + nextId: 0, + iterationPath: "Contoso\\Sprint 1", + }; + + const result = await handler({ action: "reorder", ...params }); + + expect(mockWorkApi.reorderIterationWorkItems).toHaveBeenCalledWith( + { + ids: params.ids, + previousId: undefined, + nextId: params.nextId, + parentId: undefined, + iterationPath: params.iterationPath, + }, + { project: params.project, team: params.team }, + params.iterationId + ); + expect(mockWorkApi.reorderBacklogWorkItems).not.toHaveBeenCalled(); + expect(result.content[0].text).toBe(JSON.stringify(reorderedItems, null, 2)); + }); + + it("should return an error when ids are missing", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "reorder", project: "Contoso", team: "Fabrikam" }); + + expect(result).toEqual({ + content: [{ type: "text", text: "ids is required for reorder" }], + isError: true, + }); + expect(mockWorkApi.reorderBacklogWorkItems).not.toHaveBeenCalled(); + expect(mockWorkApi.reorderIterationWorkItems).not.toHaveBeenCalled(); + }); + + it("should return an error when the reorder API fails", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); + const [, , , handler] = call; + + (mockWorkApi.reorderBacklogWorkItems as jest.Mock).mockRejectedValue(new Error("API Error")); + + const result = await handler({ action: "reorder", project: "Contoso", team: "Fabrikam", ids: [101] }); + + expect(result).toEqual({ + content: [{ type: "text", text: "Error reordering backlog work items: API Error" }], + isError: true, + }); + }); + }); + describe("my_work_items tool", () => { it("should call getPredefinedQueryResults API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); - if (!call) throw new Error("wit_my_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockResolvedValue([ @@ -245,7 +360,7 @@ describe("configureWorkItemTools", () => { includeCompleted: false, }; - const result = await handler(params); + const result = await handler({ action: "my", ...params }); expect(mockWorkApi.getPredefinedQueryResults).toHaveBeenCalledWith(params.project, params.type, params.top, params.includeCompleted); @@ -285,9 +400,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getWorkItemsBatch API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([_mockWorkItems]); @@ -297,7 +412,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); expect(mockWorkItemTrackingApi.getWorkItemsBatch).toHaveBeenCalledWith( { @@ -313,9 +428,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getWorkItemsBatch API with custom fields when fields parameter is provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockWorkItemsWithCustomFields = [ @@ -336,7 +451,7 @@ describe("configureWorkItemTools", () => { fields: ["System.Id", "System.Title"], }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); expect(mockWorkItemTrackingApi.getWorkItemsBatch).toHaveBeenCalledWith( { @@ -352,9 +467,9 @@ describe("configureWorkItemTools", () => { it("should use default fields when an empty fields array is provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([_mockWorkItems]); @@ -365,7 +480,7 @@ describe("configureWorkItemTools", () => { fields: [], // Empty array should trigger default fields }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); expect(mockWorkItemTrackingApi.getWorkItemsBatch).toHaveBeenCalledWith( { @@ -381,9 +496,9 @@ describe("configureWorkItemTools", () => { it("should transform System.AssignedTo object to formatted string", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; // Mock work items with System.AssignedTo as objects @@ -423,7 +538,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); // Parse the returned JSON to verify transformation const resultData = JSON.parse(result.content[0].text); @@ -435,9 +550,9 @@ describe("configureWorkItemTools", () => { it("should handle System.AssignedTo with only displayName", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockWorkItemsWithPartialAssignedTo = [ @@ -462,7 +577,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); const resultData = JSON.parse(result.content[0].text); expect(resultData[0].fields["System.AssignedTo"]).toBe("John Doe <>"); @@ -471,9 +586,9 @@ describe("configureWorkItemTools", () => { it("should handle System.AssignedTo with only uniqueName", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockWorkItemsWithPartialAssignedTo = [ @@ -498,7 +613,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); const resultData = JSON.parse(result.content[0].text); expect(resultData[0].fields["System.AssignedTo"]).toBe(""); @@ -507,9 +622,9 @@ describe("configureWorkItemTools", () => { it("should not transform System.AssignedTo if it's not an object", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockWorkItemsWithStringAssignedTo = [ @@ -531,7 +646,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); const resultData = JSON.parse(result.content[0].text); expect(resultData[0].fields["System.AssignedTo"]).toBe("Already a string"); @@ -540,9 +655,9 @@ describe("configureWorkItemTools", () => { it("should handle work items without System.AssignedTo field", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockWorkItemsWithoutAssignedTo = [ @@ -563,7 +678,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); const resultData = JSON.parse(result.content[0].text); expect(resultData[0].fields["System.AssignedTo"]).toBeUndefined(); @@ -572,9 +687,9 @@ describe("configureWorkItemTools", () => { it("should handle null or undefined workitems response", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue(null); @@ -584,7 +699,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); expect(result.content[0].text).toBe(JSON.stringify(null, null, 2)); }); @@ -592,9 +707,9 @@ describe("configureWorkItemTools", () => { it("should transform all user fields to formatted strings", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; // Mock work items with all user fields as objects @@ -651,7 +766,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); // Parse the returned JSON to verify transformation const resultData = JSON.parse(result.content[0].text); @@ -665,15 +780,35 @@ describe("configureWorkItemTools", () => { expect(resultData[0].fields["Microsoft.VSTS.Common.ResolvedBy"]).toBe("Diana Clark "); expect(resultData[0].fields["Microsoft.VSTS.Common.ClosedBy"]).toBe("Edward Davis "); }); + + it("should pass all ids to getWorkItemsBatch regardless of top", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([_mockWorkItem]); + + const params = { + ids: [1, 2, 3, 4, 5], + top: 2, + project: "Contoso", + }; + + await handler({ action: "get_batch", ...params }); + + expect(mockWorkItemTrackingApi.getWorkItemsBatch).toHaveBeenCalledWith(expect.objectContaining({ ids: [1, 2, 3, 4, 5] }), "Contoso"); + }); }); describe("get_work_item tool", () => { it("should call workItemApi.getWorkItem API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_item tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -686,7 +821,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(params.id, params.fields, params.asOf, params.expand, params.project); @@ -696,9 +831,9 @@ describe("configureWorkItemTools", () => { it("should call getWorkItem with fields and no expand when fields are provided but expand is empty", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_item tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -711,7 +846,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(params.id, params.fields, params.asOf, undefined, params.project); @@ -721,9 +856,9 @@ describe("configureWorkItemTools", () => { it("should call getWorkItem with expand and no fields when expand is provided but fields are empty", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_item tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -736,7 +871,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(params.id, params.fields, params.asOf, "relations", params.project); @@ -746,9 +881,9 @@ describe("configureWorkItemTools", () => { it("should override expand to 'none' when both fields and expand are provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_item tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -761,7 +896,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); // expand should be overridden to "none" because fields takes precedence expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(params.id, params.fields, params.asOf, "none", params.project); @@ -774,9 +909,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getComments API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_comments tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getComments as jest.Mock).mockResolvedValue([_mockWorkItemComments]); @@ -787,7 +922,7 @@ describe("configureWorkItemTools", () => { top: 10, }; - const result = await handler(params); + const result = await handler({ action: "list_comments", ...params }); expect(mockWorkItemTrackingApi.getComments).toHaveBeenCalledWith(params.project, params.workItemId, params.top); @@ -799,9 +934,9 @@ describe("configureWorkItemTools", () => { it("should call Add Work Item Comments API with the correct parameters and return the expected result with no format specified", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); - if (!call) throw new Error("wit_add_work_item_comment tool not registered"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -815,12 +950,12 @@ describe("configureWorkItemTools", () => { global.fetch = mockFetch; const params = { - comment: "hello world!", + text: "hello world!", project: "Contoso", workItemId: 299, }; - const result = await handler(params); + const result = await handler({ action: "add", ...params }); expect(mockFetch).toHaveBeenCalledWith( "https://dev.azure.com/contoso/Contoso/_apis/wit/workItems/299/comments?format=0&api-version=7.2-preview.4", @@ -839,9 +974,9 @@ describe("configureWorkItemTools", () => { it("should call Add Work Item Comments API with the correct parameters and return the expected result with markdown format", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); - if (!call) throw new Error("wit_add_work_item_comment tool not registered"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -855,13 +990,13 @@ describe("configureWorkItemTools", () => { global.fetch = mockFetch; const params = { - comment: "hello world!", + text: "hello world!", project: "Contoso", workItemId: 299, format: "Markdown", }; - const result = await handler(params); + const result = await handler({ action: "add", ...params }); expect(mockFetch).toHaveBeenCalledWith( "https://dev.azure.com/contoso/Contoso/_apis/wit/workItems/299/comments?format=0&api-version=7.2-preview.4", @@ -877,11 +1012,38 @@ describe("configureWorkItemTools", () => { expect(result.content[0].text).toBe(JSON.stringify(_mockWorkItemComment)); }); + it("should resolve all email mentions and preserve unresolved Markdown mentions as visible text", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: [{ id: "guid-one" }] }) }) + .mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("Not Found") }) + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: [{ id: "guid-two" }] }) }) + .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify(_mockWorkItemComment)) }); + global.fetch = mockFetch; + + await handler({ + action: "add", + project: "Contoso", + workItemId: 299, + text: "Hello @, @, and @", + }); + + expect(JSON.parse(mockFetch.mock.calls[3][1].body)).toEqual({ text: "Hello @, @<missing@example.com>, and @" }); + }); + it("should call Add Work Item Comments API with format=1 when format is Html", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); - if (!call) throw new Error("wit_add_work_item_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -889,17 +1051,59 @@ describe("configureWorkItemTools", () => { const mockFetch = jest.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve(JSON.stringify(_mockWorkItemComment)) }); global.fetch = mockFetch; - await handler({ comment: "hello world!", project: "Contoso", workItemId: 299, format: "Html" }); + await handler({ action: "add", text: "hello world!", project: "Contoso", workItemId: 299, format: "Html" }); expect(mockFetch).toHaveBeenCalledWith("https://dev.azure.com/contoso/Contoso/_apis/wit/workItems/299/comments?format=1&api-version=7.2-preview.4", expect.objectContaining({ method: "POST" })); }); + it("should use HTML mention syntax when the comment format is Html", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: [{ id: "guid-one", providerDisplayName: `Jane & "Admin" O'Neil` }] }) }) + .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify(_mockWorkItemComment)) }); + global.fetch = mockFetch; + + await handler({ action: "add", text: "Hello @", project: "Contoso", workItemId: 299, format: "Html" }); + + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ + text: 'Hello @Jane & <Doe> "Admin" O'Neil', + }); + }); + + it("should preserve unresolved email mentions as visible text when the comment format is Html", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: [] }) }) + .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify(_mockWorkItemComment)) }); + global.fetch = mockFetch; + + await handler({ action: "add", text: "Hello @", project: "Contoso", workItemId: 299, format: "Html" }); + + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ text: "Hello @<missing@example.com>" }); + }); + it("should handle fetch failure response", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); - if (!call) throw new Error("wit_add_work_item_comment tool not registered"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -913,12 +1117,12 @@ describe("configureWorkItemTools", () => { global.fetch = mockFetch; const params = { - comment: "hello world!", + text: "hello world!", project: "Contoso", workItemId: 299, }; - const result = await handler(params); + const result = await handler({ action: "add", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error adding work item comment"); expect(result.content[0].text).toContain("Failed to add a work item comment: Not Found"); @@ -927,8 +1131,8 @@ describe("configureWorkItemTools", () => { it("should encode the project parameter to prevent URL path injection", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); - if (!call) throw new Error("wit_add_work_item_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -942,27 +1146,51 @@ describe("configureWorkItemTools", () => { const maliciousProject = "../../_apis/hooks/subscriptions"; const params = { - comment: "attacker-controlled body content", + text: "attacker-controlled body content", project: maliciousProject, workItemId: 1, }; - await handler(params); + await handler({ action: "add", ...params }); const calledUrl = mockFetch.mock.calls[0][0] as string; // The project must be encoded in the URL to prevent path traversal expect(calledUrl).toContain(encodeURIComponent(maliciousProject)); expect(calledUrl).not.toContain("../../"); }); + + it("should return error when workItemId is missing", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "add", project: "P", text: "hello" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemId is required"); + }); + + it("should return error when text is missing", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "add", project: "P", workItemId: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("text is required"); + }); }); describe("update_work_item_comment tool", () => { it("should update a work item comment", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item_comment"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); - if (!call) throw new Error("wit_update_work_item_comment tool not registered"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -989,7 +1217,7 @@ describe("configureWorkItemTools", () => { text: "Updated comment text", }; - const result = await handler(params); + const result = await handler({ action: "update", ...params }); expect(mockFetch).toHaveBeenCalledWith( "https://dev.azure.com/contoso/TestProject/_apis/wit/workItems/42/comments/100?format=0&api-version=7.2-preview.4", @@ -1006,12 +1234,39 @@ describe("configureWorkItemTools", () => { expect(parsed.text).toBe("Updated comment text"); }); + it("should resolve repeated email mentions before updating a comment", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ value: [{ id: "guid-one" }] }) }) + .mockResolvedValueOnce({ ok: true, text: () => Promise.resolve(JSON.stringify(_mockWorkItemComment)) }); + global.fetch = mockFetch; + + await handler({ + action: "update", + project: "Contoso", + workItemId: 299, + commentId: 1, + text: "@ follow up with @", + }); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(JSON.parse(mockFetch.mock.calls[1][1].body)).toEqual({ text: "@ follow up with @" }); + }); + it("should handle update work item comment failure", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item_comment"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); - if (!call) throw new Error("wit_update_work_item_comment tool not registered"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1030,7 +1285,7 @@ describe("configureWorkItemTools", () => { text: "This should fail", }; - const result = await handler(params); + const result = await handler({ action: "update", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error updating work item comment"); expect(result.content[0].text).toContain("Failed to update work item comment: Not Found"); @@ -1039,8 +1294,8 @@ describe("configureWorkItemTools", () => { it("should encode the project parameter to prevent URL path injection", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item_comment"); - if (!call) throw new Error("wit_update_work_item_comment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1060,22 +1315,34 @@ describe("configureWorkItemTools", () => { text: "attacker-controlled body", }; - await handler(params); + await handler({ action: "update", ...params }); const calledUrl = mockFetch.mock.calls[0][0] as string; // The project must be encoded in the URL to prevent path traversal expect(calledUrl).toContain(encodeURIComponent(maliciousProject)); expect(calledUrl).not.toContain("../../"); }); + + it("should return error when commentId is missing for update", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "update", project: "P", workItemId: 1, text: "updated" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("commentId is required for update"); + }); }); describe("link_work_item_to_pull_request tool", () => { it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1104,7 +1371,7 @@ describe("configureWorkItemTools", () => { }, ]; - const result = await handler(params); + const result = await handler({ action: "link_to_pull_request", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, document, params.workItemId, params.projectId); @@ -1123,9 +1390,9 @@ describe("configureWorkItemTools", () => { it("should handle errors from updateWorkItem and return a descriptive error", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API failure")); @@ -1136,7 +1403,7 @@ describe("configureWorkItemTools", () => { pullRequestId: 67890, workItemId: 131489, }; - const result = await handler(params); + const result = await handler({ action: "link_to_pull_request", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("API failure"); @@ -1144,8 +1411,8 @@ describe("configureWorkItemTools", () => { it("should encode special characters in projectId and repositoryId for vstfsUrl", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1171,14 +1438,14 @@ describe("configureWorkItemTools", () => { }, }, ]; - await handler(params); + await handler({ action: "link_to_pull_request", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, document, params.workItemId, params.projectId); }); it("should use pullRequestProjectId instead of projectId when provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1208,7 +1475,7 @@ describe("configureWorkItemTools", () => { }, }, ]; - await handler(params); + await handler({ action: "link_to_pull_request", ...params }); // Note: Work item should still be updated in the original project expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, document, params.workItemId, params.projectId); @@ -1216,8 +1483,8 @@ describe("configureWorkItemTools", () => { it("should fall back to projectId when pullRequestProjectId is empty", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1248,16 +1515,64 @@ describe("configureWorkItemTools", () => { }, }, ]; - await handler(params); + await handler({ action: "link_to_pull_request", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, document, params.workItemId, params.projectId); }); + it("should return error when projectId is missing for link_to_pull_request", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link_to_pull_request", repositoryId: "repo-1", pullRequestId: 1, workItemId: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("projectId is required for link_to_pull_request"); + }); + + it("should return error when repositoryId is missing for link_to_pull_request", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link_to_pull_request", projectId: "proj-1", pullRequestId: 1, workItemId: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("repositoryId is required for link_to_pull_request"); + }); + + it("should return error when pullRequestId is missing for link_to_pull_request", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link_to_pull_request", projectId: "proj-1", repositoryId: "repo-1", workItemId: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("pullRequestId is required for link_to_pull_request"); + }); + + it("should return error when workItemId is missing for link_to_pull_request", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link_to_pull_request", projectId: "proj-1", repositoryId: "repo-1", pullRequestId: 42 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemId is required for link_to_pull_request"); + }); + it("should handle link_work_item_to_pull_request unknown error type", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Simulate an unknown error type (not an Error instance) @@ -1271,7 +1586,7 @@ describe("configureWorkItemTools", () => { pullRequestProjectId: "other-project", }; - const result = await handler(params); + const result = await handler({ action: "link_to_pull_request", ...params }); expect(result.content[0].text).toBe("Error linking work item to pull request: Unknown error occurred"); expect(result.isError).toBe(true); @@ -1282,9 +1597,9 @@ describe("configureWorkItemTools", () => { it("should call workApi.getIterationWorkItems API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_items_for_iterationt tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkApi.getIterationWorkItems as jest.Mock).mockResolvedValue([_mockWorkItemsForIteration]); @@ -1295,7 +1610,7 @@ describe("configureWorkItemTools", () => { iterationId: "6bfde89e-b22e-422e-814a-e8db432f5a58", }; - const result = await handler(params); + const result = await handler({ action: "list_for_iteration", ...params }); expect(mockWorkApi.getIterationWorkItems).toHaveBeenCalledWith( { @@ -1313,9 +1628,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getRevisions API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue(_mockWorkItemRevisions); @@ -1326,7 +1641,7 @@ describe("configureWorkItemTools", () => { top: 10, }; - const result = await handler(params); + const result = await handler({ action: "list_revisions", ...params }); expect(mockWorkItemTrackingApi.getRevisions).toHaveBeenCalledWith(params.workItemId, params.top, undefined, undefined, params.project); @@ -1336,9 +1651,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getRevisions API with expand parameter", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue(_mockWorkItemRevisions); @@ -1351,7 +1666,7 @@ describe("configureWorkItemTools", () => { expand: "Relations", }; - const result = await handler(params); + const result = await handler({ action: "list_revisions", ...params }); expect(mockWorkItemTrackingApi.getRevisions).toHaveBeenCalledWith(params.workItemId, params.top, params.skip, 1, params.project); @@ -1361,9 +1676,9 @@ describe("configureWorkItemTools", () => { it("should clean up identity fields by removing unwanted properties", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; // Create a deep copy of mock data to avoid mutating the original @@ -1377,7 +1692,7 @@ describe("configureWorkItemTools", () => { expand: "fields", }; - const result = await handler(params); + const result = await handler({ action: "list_revisions", ...params }); const parsedResult = JSON.parse(result.content[0].text); @@ -1395,9 +1710,9 @@ describe("configureWorkItemTools", () => { it("should handle revisions with no identity fields without errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; const mockRevisionsWithoutIdentities = [ @@ -1420,7 +1735,7 @@ describe("configureWorkItemTools", () => { top: 25, }; - const result = await handler(params); + const result = await handler({ action: "list_revisions", ...params }); expect(mockWorkItemTrackingApi.getRevisions).toHaveBeenCalledWith(params.workItemId, 25, undefined, undefined, params.project); expect(result.content[0].text).toBe(JSON.stringify(mockRevisionsWithoutIdentities, null, 2)); @@ -1429,9 +1744,9 @@ describe("configureWorkItemTools", () => { it("should use default top value of 50 when not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue(_mockWorkItemRevisions); @@ -1442,7 +1757,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - await handler(params); + await handler({ action: "list_revisions", ...params }); expect(mockWorkItemTrackingApi.getRevisions).toHaveBeenCalledWith(299, 50, undefined, undefined, "Contoso"); }); @@ -1452,9 +1767,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.updateWorkItem API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_update_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -1475,7 +1790,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update", ...params }); // In line 456-471, the operation is actually not transformed to lowercase // despite the comment saying otherwise, so we use the original value @@ -1491,9 +1806,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getWorkItemType API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); - if (!call) throw new Error("wit_get_work_item_type tool not registered"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockResolvedValue([_mockWorkItemType]); @@ -1503,7 +1818,7 @@ describe("configureWorkItemTools", () => { workItemType: "Bug", }; - const result = await handler(params); + const result = await handler({ action: "get_type", ...params }); expect(mockWorkItemTrackingApi.getWorkItemType).toHaveBeenCalledWith(params.project, params.workItemType); @@ -1515,9 +1830,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.createWorkItem API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -1538,7 +1853,7 @@ describe("configureWorkItemTools", () => { { op: "add", path: "/fields/System.AreaPath", value: "Contoso\\Development" }, ]; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledWith(null, expectedDocument, params.project, params.workItemType); @@ -1548,9 +1863,9 @@ describe("configureWorkItemTools", () => { it("should handle Markdown format for long fields", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); @@ -1572,7 +1887,41 @@ describe("configureWorkItemTools", () => { { op: "add", path: "/multilineFieldsFormat/System.Description", value: "Markdown" }, ]; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); + + expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledWith(null, expectedDocument, params.project, params.workItemType); + + expect(result.content[0].text).toBe(JSON.stringify(_mockWorkItem, null, 2)); + }); + + it("should handle Markdown format for short fields", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + + if (!call) throw new Error("wit_work_item_write tool not registered"); + const [, , , handler] = call; + + (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(_mockWorkItem); + + const shortDescription = "Short markdown"; + + const params = { + project: "Contoso", + workItemType: "Task", + fields: [ + { name: "System.Title", value: "Hello World!" }, + { name: "System.Description", value: shortDescription, format: "Markdown" }, + ], + }; + + const expectedDocument = [ + { op: "add", path: "/fields/System.Title", value: "Hello World!" }, + { op: "add", path: "/fields/System.Description", value: shortDescription }, + { op: "add", path: "/multilineFieldsFormat/System.Description", value: "Markdown" }, + ]; + + const result = await handler({ action: "create", ...params }); expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledWith(null, expectedDocument, params.project, params.workItemType); @@ -1582,9 +1931,9 @@ describe("configureWorkItemTools", () => { it("should handle null response from createWorkItem", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(null); @@ -1595,7 +1944,7 @@ describe("configureWorkItemTools", () => { fields: [{ name: "System.Title", value: "Test" }], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Work item was not created"); @@ -1604,9 +1953,9 @@ describe("configureWorkItemTools", () => { it("should handle errors from createWorkItem", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API failure")); @@ -1617,7 +1966,7 @@ describe("configureWorkItemTools", () => { fields: [{ name: "System.Title", value: "Test" }], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error creating work item: API failure"); @@ -1626,9 +1975,9 @@ describe("configureWorkItemTools", () => { it("should handle unknown error types", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue("String error"); @@ -1639,7 +1988,7 @@ describe("configureWorkItemTools", () => { fields: [{ name: "System.Title", value: "Test" }], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error creating work item: Unknown error occurred"); @@ -1650,9 +1999,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getQuery API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); - if (!call) throw new Error("wit_get_query tool not registered"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getQuery as jest.Mock).mockResolvedValue([_mockQuery]); @@ -1666,7 +2015,7 @@ describe("configureWorkItemTools", () => { useIsoDateFormat: false, }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(mockWorkItemTrackingApi.getQuery).toHaveBeenCalledWith(params.project, params.query, QueryExpand.None, params.depth, params.includeDeleted, params.useIsoDateFormat); @@ -1678,9 +2027,9 @@ describe("configureWorkItemTools", () => { it("should call workItemApi.getQueryById API with the correct parameters and return the expected result", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); - if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.queryById as jest.Mock).mockResolvedValue([_mockQueryResults]); @@ -1693,7 +2042,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "get_results", ...params }); expect(mockWorkItemTrackingApi.queryById).toHaveBeenCalledWith(params.id, { project: params.project, team: params.team }, params.timePrecision, params.top); @@ -1705,8 +2054,8 @@ describe("configureWorkItemTools", () => { it("should handle all link types through work_items_link tool", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Mock the connection and serverUrl @@ -1737,7 +2086,7 @@ describe("configureWorkItemTools", () => { ], }; - await handler(params); + await handler({ action: "link", ...params }); } expect(fetch).toHaveBeenCalled(); @@ -1746,8 +2095,8 @@ describe("configureWorkItemTools", () => { it("should throw error for unknown link type", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1764,7 +2113,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error linking work items"); expect(result.content[0].text).toContain("Unknown link type: unknown_type"); @@ -1775,8 +2124,8 @@ describe("configureWorkItemTools", () => { it("should update work items in batch successfully", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); - if (!call) throw new Error("wit_update_work_items_batch tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1788,7 +2137,7 @@ describe("configureWorkItemTools", () => { }); const params = { - updates: [ + batchUpdates: [ { op: "replace", id: 1, @@ -1804,7 +2153,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update_batch", ...params }); // This verifies that the updates are grouped by work item ID as implemented in line 643 const expectedBody = [ @@ -1840,8 +2189,8 @@ describe("configureWorkItemTools", () => { it("should handle Markdown format for large text fields", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); - if (!call) throw new Error("wit_update_work_items_batch tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1855,7 +2204,7 @@ describe("configureWorkItemTools", () => { const longDescription = "This is a very long description that is definitely more than 50 characters long and should trigger Markdown formatting"; const params = { - updates: [ + batchUpdates: [ { op: "Add", // Match the capitalization in the implementation id: 1, @@ -1872,7 +2221,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update_batch", ...params }); // This verifies that the Markdown format is applied for the long text field as implemented in line 643 const expectedBody = [ @@ -1907,11 +2256,73 @@ describe("configureWorkItemTools", () => { expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, success: true }], null, 2)); }); + it("should handle Markdown format for short fields in batch update", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue([{ id: 1, success: true }]), + }); + + const shortDescription = "Short markdown"; + + const params = { + batchUpdates: [ + { + op: "Add", + id: 1, + path: "/fields/System.Description", + value: shortDescription, + format: "Markdown", + }, + ], + }; + + const result = await handler({ action: "update_batch", ...params }); + + const expectedBody = [ + { + method: "PATCH", + uri: "/_apis/wit/workitems/1?api-version=5.0", + headers: { "Content-Type": "application/json-patch+json" }, + body: [ + { op: "Add", path: "/fields/System.Description", value: shortDescription }, + { + op: "Add", + path: "/multilineFieldsFormat/System.Description", + value: "Markdown", + }, + ], + }, + ]; + + expect(fetch).toHaveBeenCalledWith( + "https://dev.azure.com/contoso/_apis/wit/$batch?api-version=5.0", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ + "Authorization": "Bearer fake-token", + "Content-Type": "application/json", + }), + body: JSON.stringify(expectedBody), + }) + ); + + expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, success: true }], null, 2)); + }); + it("should handle batch update failure", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); - if (!call) throw new Error("wit_update_work_items_batch tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1923,7 +2334,7 @@ describe("configureWorkItemTools", () => { }); const params = { - updates: [ + batchUpdates: [ { op: "replace", id: 1, @@ -1933,7 +2344,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update_batch", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error updating work items in batch"); expect(result.content[0].text).toContain("Failed to update work items in batch: Bad Request"); @@ -1944,8 +2355,8 @@ describe("configureWorkItemTools", () => { it("should link work items successfully", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -1965,30 +2376,103 @@ describe("configureWorkItemTools", () => { type: "related", comment: "Related work item", }, - ], - }; + ], + }; + + const result = await handler({ action: "link", ...params }); + + expect(fetch).toHaveBeenCalledWith( + "https://dev.azure.com/contoso/_apis/wit/$batch?api-version=5.0", + expect.objectContaining({ + method: "PATCH", + headers: expect.objectContaining({ + "Authorization": "Bearer fake-token", + "Content-Type": "application/json", + }), + }) + ); + + expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, success: true }], null, 2)); + }); + + it("should add a hyperlink to a work item", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , schema, handler] = call; + + expect( + z.object(schema).parse({ + action: "link", + project: "TestProject", + updates: [{ id: 1, type: "hyperlink", url: "https://www.google.com/", comment: "Search" }], + }) + ).toEqual({ + action: "link", + project: "TestProject", + updates: [{ id: 1, type: "hyperlink", url: "https://www.google.com/", comment: "Search" }], + }); + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([{ id: 1, success: true }]), + }); + + await handler({ + action: "link", + project: "TestProject", + updates: [{ id: 1, type: "hyperlink", url: "https://www.google.com/", comment: "Search" }], + }); + + const request = (fetch as jest.Mock).mock.calls[0][1]; + const body = JSON.parse(request.body); + expect(body[0].body).toEqual([ + { + op: "add", + path: "/relations/-", + value: { + rel: "Hyperlink", + url: "https://www.google.com/", + attributes: { comment: "Search" }, + }, + }, + ]); + }); - const result = await handler(params); + it("should require a URL when adding a hyperlink", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - expect(fetch).toHaveBeenCalledWith( - "https://dev.azure.com/contoso/_apis/wit/$batch?api-version=5.0", - expect.objectContaining({ - method: "PATCH", - headers: expect.objectContaining({ - "Authorization": "Bearer fake-token", - "Content-Type": "application/json", - }), - }) - ); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; - expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, success: true }], null, 2)); + const result = await handler({ action: "link", project: "TestProject", updates: [{ id: 1, type: "hyperlink" }] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error linking work items: url is required for hyperlink links"); + }); + + it("should require linkToId when linking work items", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link", project: "TestProject", updates: [{ id: 1, type: "related" }] }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error linking work items: linkToId is required for work item links"); }); it("should handle linking failure", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2011,19 +2495,31 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toContain("Error linking work items"); expect(result.content[0].text).toContain("Failed to update work items in batch: Unauthorized"); }); + + it("should return error when updates is missing for link", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "link", project: "TestProject" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("updates is required for link"); + }); }); describe("work_item_unlink tool", () => { it("should unlink work items successfully", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Mock work item with relations @@ -2065,7 +2561,7 @@ describe("configureWorkItemTools", () => { url: "https://dev.azure.com/contoso/_apis/wit/workItems/2", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(1, undefined, undefined, 1, "TestProject"); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith(null, [{ op: "remove", path: "/relations/0" }], 1, "TestProject"); @@ -2078,8 +2574,8 @@ describe("configureWorkItemTools", () => { it("should unlink all links of a specific type when no URL is provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Mock work item with multiple related links @@ -2125,7 +2621,7 @@ describe("configureWorkItemTools", () => { type: "related", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(1, undefined, undefined, 1, "TestProject"); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( @@ -2145,8 +2641,8 @@ describe("configureWorkItemTools", () => { it("should handle artifact link removal", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItemWithRelations = { @@ -2187,7 +2683,7 @@ describe("configureWorkItemTools", () => { url: "vstfs:///Git/Ref/project%2Frepo%2Fbranch", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith(null, [{ op: "remove", path: "/relations/0" }], 1, "TestProject"); @@ -2198,8 +2694,8 @@ describe("configureWorkItemTools", () => { it("should handle when no matching relations are found", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItemWithRelations = { @@ -2222,7 +2718,7 @@ describe("configureWorkItemTools", () => { url: "https://dev.azure.com/contoso/_apis/wit/workItems/999", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).not.toHaveBeenCalled(); expect(result.content[0].text).toContain("No matching relations found for link type 'related' and URL 'https://dev.azure.com/contoso/_apis/wit/workItems/999'"); @@ -2232,8 +2728,8 @@ describe("configureWorkItemTools", () => { it("should handle updateWorkItem API failure", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItemWithRelations = { @@ -2257,7 +2753,7 @@ describe("configureWorkItemTools", () => { url: "https://dev.azure.com/contoso/_apis/wit/workItems/2", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error unlinking work item: Update failed"); @@ -2266,8 +2762,8 @@ describe("configureWorkItemTools", () => { it("should handle getWorkItem API failure", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockRejectedValue(new Error("Work item not found")); @@ -2278,7 +2774,7 @@ describe("configureWorkItemTools", () => { type: "related", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error unlinking work item: Work item not found"); @@ -2287,8 +2783,8 @@ describe("configureWorkItemTools", () => { it("should handle work items with no relations", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItemWithNoRelations = { @@ -2304,7 +2800,7 @@ describe("configureWorkItemTools", () => { type: "related", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(result.content[0].text).toContain("No matching relations found for link type 'related'"); expect(result.isError).toBe(true); @@ -2313,8 +2809,8 @@ describe("configureWorkItemTools", () => { it("should handle specific URL matching correctly", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItemWithRelations = { @@ -2353,7 +2849,7 @@ describe("configureWorkItemTools", () => { url: "https://dev.azure.com/contoso/_apis/wit/workItems/2", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); // Should remove only the matching relation at index 0 expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith(null, [{ op: "remove", path: "/relations/0" }], 1, "TestProject"); @@ -2365,8 +2861,8 @@ describe("configureWorkItemTools", () => { it("should throw error for unknown link type in work_item_unlink", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Mock a work item with some relations (this won't matter since we'll hit the error before processing them) @@ -2383,17 +2879,43 @@ describe("configureWorkItemTools", () => { type: "unknown_type", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error unlinking work item: Unknown link type: unknown_type"); }); + it("should return error when id is missing for unlink", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "unlink", project: "TestProject", type: "related" }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("id is required for unlink"); + }); + + it("should return error when type is missing for unlink", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ action: "unlink", project: "TestProject", id: 1 }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("type is required for unlink"); + }); + it("should handle unknown error types in work_item_unlink", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Simulate an unknown error type (not an Error instance) @@ -2405,7 +2927,7 @@ describe("configureWorkItemTools", () => { type: "related", }; - const result = await handler(params); + const result = await handler({ action: "unlink", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error unlinking work item: Unknown error occurred"); @@ -2421,8 +2943,8 @@ describe("configureWorkItemTools", () => { const getUnlinkHandler = () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); return call[3]; }; @@ -2433,7 +2955,7 @@ describe("configureWorkItemTools", () => { const handler = getUnlinkHandler(); (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue({ id: 1, relations }); - const result = await handler({ project: "TestProject", id: 1, type, url }); + const result = await handler({ action: "unlink", project: "TestProject", id: 1, type, url }); expect(mockWorkItemTrackingApi.updateWorkItem).not.toHaveBeenCalled(); expect(result.isError).toBe(true); @@ -2445,7 +2967,7 @@ describe("configureWorkItemTools", () => { (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue({ id: 1, relations }); (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 1, rev: 10 }); - const result = await handler({ project: "TestProject", id: 1, type: "related", url: relatedUrl }); + const result = await handler({ action: "unlink", project: "TestProject", id: 1, type: "related", url: relatedUrl }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith(null, [{ op: "remove", path: "/relations/1" }], 1, "TestProject"); expect(result.isError).toBe(false); @@ -2459,8 +2981,8 @@ describe("configureWorkItemTools", () => { it("should handle create_work_item errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2474,7 +2996,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.content[0].text).toBe("Error creating work item: API Error"); expect(result.isError).toBe(true); @@ -2483,8 +3005,8 @@ describe("configureWorkItemTools", () => { it("should handle create_work_item null response", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue(null); @@ -2495,7 +3017,7 @@ describe("configureWorkItemTools", () => { fields: [{ name: "System.Title", value: "Test Task" }], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.content[0].text).toBe("Work item was not created"); expect(result.isError).toBe(true); @@ -2504,8 +3026,8 @@ describe("configureWorkItemTools", () => { it("should handle link_work_item_to_pull_request errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("Linking failed")); @@ -2518,7 +3040,7 @@ describe("configureWorkItemTools", () => { pullRequestProjectId: "OtherProject", }; - const result = await handler(params); + const result = await handler({ action: "link_to_pull_request", ...params }); expect(result.content[0].text).toBe("Error linking work item to pull request: Linking failed"); expect(result.isError).toBe(true); @@ -2527,8 +3049,8 @@ describe("configureWorkItemTools", () => { it("should handle link_work_item_to_pull_request null response", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_link_work_item_to_pull_request"); - if (!call) throw new Error("wit_link_work_item_to_pull_request tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue(null); @@ -2541,7 +3063,7 @@ describe("configureWorkItemTools", () => { pullRequestProjectId: "OtherProject", }; - const result = await handler(params); + const result = await handler({ action: "link_to_pull_request", ...params }); expect(result.content[0].text).toBe("Work item update failed"); expect(result.isError).toBe(true); @@ -2550,8 +3072,8 @@ describe("configureWorkItemTools", () => { it("should handle create_work_item unknown error type", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); - if (!call) throw new Error("wit_create_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; // Simulate an unknown error type (not an Error instance) @@ -2563,7 +3085,7 @@ describe("configureWorkItemTools", () => { fields: [{ name: "System.Title", value: "Test Task" }], }; - const result = await handler(params); + const result = await handler({ action: "create", ...params }); expect(result.content[0].text).toBe("Error creating work item: Unknown error occurred"); expect(result.isError).toBe(true); @@ -2572,8 +3094,8 @@ describe("configureWorkItemTools", () => { it("should handle work_items_link with empty comment", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2596,7 +3118,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "link", ...params }); expect(fetch).toHaveBeenCalled(); expect(result.content[0].text).toBe(JSON.stringify([{ id: 1, success: true }], null, 2)); @@ -2608,8 +3130,8 @@ describe("configureWorkItemTools", () => { it("should handle add_child_work_item with optional parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2636,7 +3158,7 @@ describe("configureWorkItemTools", () => { ], }; - await handler(params); + await handler({ action: "add_child", ...params }); expect(mockFetch).toHaveBeenCalledWith( "https://dev.azure.com/contoso/_apis/wit/$batch?api-version=5.0", @@ -2654,8 +3176,8 @@ describe("configureWorkItemTools", () => { it("should handle add_child_work_item with empty optional parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2682,7 +3204,7 @@ describe("configureWorkItemTools", () => { ], }; - await handler(params); + await handler({ action: "add_child", ...params }); // Should not include area or iteration path since they're empty/whitespace expect(mockFetch).toHaveBeenCalledWith( @@ -2703,8 +3225,8 @@ describe("configureWorkItemTools", () => { it("should reject when more than 50 items are provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2723,7 +3245,7 @@ describe("configureWorkItemTools", () => { items, }; - const result = await handler(params); + const result = await handler({ action: "add_child", ...params }); expect(result.content[0].text).toBe("A maximum of 50 child work items can be created in a single call."); expect(result.isError).toBe(true); @@ -2732,8 +3254,8 @@ describe("configureWorkItemTools", () => { it("should handle Markdown format correctly", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2759,7 +3281,7 @@ describe("configureWorkItemTools", () => { ], }; - await handler(params); + await handler({ action: "add_child", ...params }); // Should include Markdown format fields expect(mockFetch).toHaveBeenCalledWith( @@ -2780,8 +3302,8 @@ describe("configureWorkItemTools", () => { it("should handle fetch failure response", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2806,7 +3328,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "add_child", ...params }); expect(result.content[0].text).toBe("Error creating child work items: Failed to update work items in batch: Internal Server Error"); expect(result.isError).toBe(true); @@ -2815,8 +3337,8 @@ describe("configureWorkItemTools", () => { it("should handle unknown error types in add_child_work_items", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2836,7 +3358,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "add_child", ...params }); expect(result.content[0].text).toBe("Error creating child work items: Unknown error occurred"); expect(result.isError).toBe(true); @@ -2845,8 +3367,8 @@ describe("configureWorkItemTools", () => { it("should encode the project parameter in batch request URI to prevent path injection", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2866,7 +3388,7 @@ describe("configureWorkItemTools", () => { items: [{ title: "Test", description: "Test" }], }; - await handler(params); + await handler({ action: "add_child", ...params }); const calledBody = JSON.parse(mockFetch.mock.calls[0][1].body); const batchUri = calledBody[0].uri as string; @@ -2878,8 +3400,8 @@ describe("configureWorkItemTools", () => { it("should encode the workItemType parameter in batch request URI to prevent path injection", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -2899,7 +3421,7 @@ describe("configureWorkItemTools", () => { items: [{ title: "Test", description: "Test" }], }; - await handler(params); + await handler({ action: "add_child", ...params }); const calledBody = JSON.parse(mockFetch.mock.calls[0][1].body); const batchUri = calledBody[0].uri as string; @@ -2913,8 +3435,8 @@ describe("configureWorkItemTools", () => { it("should handle list_backlogs errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); const [, , , handler] = call; (mockWorkApi.getBacklogs as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2924,7 +3446,7 @@ describe("configureWorkItemTools", () => { team: "Fabrikam", }; - const result = await handler(params); + const result = await handler({ action: "list", ...params }); expect(result.content[0].text).toBe("Error listing backlogs: API Error"); expect(result.isError).toBe(true); @@ -2933,8 +3455,8 @@ describe("configureWorkItemTools", () => { it("should handle list_backlog_work_items errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog tool not registered"); const [, , , handler] = call; (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2945,7 +3467,7 @@ describe("configureWorkItemTools", () => { backlogId: "Microsoft.FeatureCategory", }; - const result = await handler(params); + const result = await handler({ action: "list_work_items", ...params }); expect(result.content[0].text).toBe("Error listing backlog work items: API Error"); expect(result.isError).toBe(true); @@ -2954,8 +3476,8 @@ describe("configureWorkItemTools", () => { it("should handle my_work_items errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); - if (!call) throw new Error("wit_my_work_items tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2967,7 +3489,7 @@ describe("configureWorkItemTools", () => { includeCompleted: false, }; - const result = await handler(params); + const result = await handler({ action: "my", ...params }); expect(result.content[0].text).toBe("Error retrieving work items: API Error"); expect(result.isError).toBe(true); @@ -2976,8 +3498,8 @@ describe("configureWorkItemTools", () => { it("should handle get_work_items_batch_by_ids errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -2987,7 +3509,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get_batch", ...params }); expect(result.content[0].text).toBe("Error retrieving work items batch: API Error"); expect(result.isError).toBe(true); @@ -2996,8 +3518,8 @@ describe("configureWorkItemTools", () => { it("should handle get_work_item errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); - if (!call) throw new Error("wit_get_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3007,7 +3529,7 @@ describe("configureWorkItemTools", () => { project: "Contoso", }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(result.content[0].text).toBe("Error retrieving work item: API Error"); expect(result.isError).toBe(true); @@ -3016,8 +3538,8 @@ describe("configureWorkItemTools", () => { it("should handle list_work_item_comments errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); - if (!call) throw new Error("wit_list_work_item_comments tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getComments as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3028,7 +3550,7 @@ describe("configureWorkItemTools", () => { top: 10, }; - const result = await handler(params); + const result = await handler({ action: "list_comments", ...params }); expect(result.content[0].text).toBe("Error listing work item comments: API Error"); expect(result.isError).toBe(true); @@ -3037,8 +3559,8 @@ describe("configureWorkItemTools", () => { it("should handle list_work_item_revisions errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); - if (!call) throw new Error("wit_list_work_item_revisions tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3049,7 +3571,7 @@ describe("configureWorkItemTools", () => { top: 10, }; - const result = await handler(params); + const result = await handler({ action: "list_revisions", ...params }); expect(result.content[0].text).toBe("Error listing work item revisions: API Error"); expect(result.isError).toBe(true); @@ -3058,8 +3580,8 @@ describe("configureWorkItemTools", () => { it("should handle get_work_items_for_iteration errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); - if (!call) throw new Error("wit_get_work_items_for_iteration tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkApi.getIterationWorkItems as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3070,7 +3592,7 @@ describe("configureWorkItemTools", () => { iterationId: "abc-123", }; - const result = await handler(params); + const result = await handler({ action: "list_for_iteration", ...params }); expect(result.content[0].text).toBe("Error retrieving work items for iteration: API Error"); expect(result.isError).toBe(true); @@ -3079,8 +3601,8 @@ describe("configureWorkItemTools", () => { it("should handle update_work_item errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); - if (!call) throw new Error("wit_update_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3096,7 +3618,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update", ...params }); expect(result.content[0].text).toBe("Error updating work item: API Error"); expect(result.isError).toBe(true); @@ -3105,8 +3627,8 @@ describe("configureWorkItemTools", () => { it("should handle update_work_item with lowercase operation transformation", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); - if (!call) throw new Error("wit_update_work_item tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue([_mockWorkItem]); @@ -3123,7 +3645,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update", ...params }); // The operation value is kept as-is per the implementation expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalled(); @@ -3133,8 +3655,8 @@ describe("configureWorkItemTools", () => { it("should handle get_work_item_type errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); - if (!call) throw new Error("wit_get_work_item_type tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3144,7 +3666,7 @@ describe("configureWorkItemTools", () => { workItemType: "Bug", }; - const result = await handler(params); + const result = await handler({ action: "get_type", ...params }); expect(result.content[0].text).toBe("Error retrieving work item type: API Error"); expect(result.isError).toBe(true); @@ -3153,8 +3675,8 @@ describe("configureWorkItemTools", () => { it("should handle get_query errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); - if (!call) throw new Error("wit_get_query tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.getQuery as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3167,7 +3689,7 @@ describe("configureWorkItemTools", () => { useIsoDateFormat: false, }; - const result = await handler(params); + const result = await handler({ action: "get", ...params }); expect(result.content[0].text).toBe("Error retrieving query: API Error"); expect(result.isError).toBe(true); @@ -3176,8 +3698,8 @@ describe("configureWorkItemTools", () => { it("should handle get_query_results_by_id errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); - if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.queryById as jest.Mock).mockRejectedValue(new Error("API Error")); @@ -3190,7 +3712,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "get_results", ...params }); expect(result.content[0].text).toBe("Error retrieving query results: API Error"); expect(result.isError).toBe(true); @@ -3199,8 +3721,8 @@ describe("configureWorkItemTools", () => { it("should handle get_query_results_by_id with responseType ids", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query_results_by_id"); - if (!call) throw new Error("wit_get_query_results_by_id tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; const mockQueryResultsWithIds = { @@ -3215,7 +3737,7 @@ describe("configureWorkItemTools", () => { responseType: "ids", }; - const result = await handler(params); + const result = await handler({ action: "get_results", ...params }); const parsedResult = JSON.parse(result.content[0].text); expect(parsedResult.ids).toEqual([1, 2, 3]); @@ -3225,15 +3747,15 @@ describe("configureWorkItemTools", () => { it("should handle update_work_items_batch errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_items_batch"); - if (!call) throw new Error("wit_update_work_items_batch tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockRejectedValue(new Error("Token error")); const params = { - updates: [ + batchUpdates: [ { op: "replace", id: 1, @@ -3243,7 +3765,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "update_batch", ...params }); expect(result.content[0].text).toBe("Error updating work items in batch: Token error"); expect(result.isError).toBe(true); @@ -3252,8 +3774,8 @@ describe("configureWorkItemTools", () => { it("should handle work_items_link errors", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockConnection.serverUrl = "https://dev.azure.com/contoso"; @@ -3270,7 +3792,7 @@ describe("configureWorkItemTools", () => { ], }; - const result = await handler(params); + const result = await handler({ action: "link", ...params }); expect(result.content[0].text).toBe("Error linking work items: Token error"); expect(result.isError).toBe(true); @@ -3279,8 +3801,8 @@ describe("configureWorkItemTools", () => { it("should handle add_artifact_link with unknown error type", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockWorkItemTrackingApi.updateWorkItem.mockRejectedValue("String error"); @@ -3292,7 +3814,7 @@ describe("configureWorkItemTools", () => { linkType: "Branch", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.content[0].text).toBe("Error adding artifact link to work item: Unknown error occurred"); expect(result.isError).toBe(true); @@ -3300,12 +3822,12 @@ describe("configureWorkItemTools", () => { }); describe("artifact link tools", () => { - describe("wit_add_artifact_link", () => { + describe("wit_work_item_link_write", () => { it("should add artifact link to work item successfully", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3319,7 +3841,7 @@ describe("configureWorkItemTools", () => { comment: "Linked to main branch", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3352,8 +3874,8 @@ describe("configureWorkItemTools", () => { it("should add artifact link without comment", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3366,7 +3888,7 @@ describe("configureWorkItemTools", () => { linkType: "Commit", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3391,11 +3913,55 @@ describe("configureWorkItemTools", () => { expect(response.comment).toBe(null); }); + it("should return error when workItemId is missing for add_artifact_link", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ + action: "add_artifact_link", + project: "TestProject", + artifactUri: "vstfs:///Git/Ref/test", + linkType: "Branch", + // workItemId intentionally omitted + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemId is required for add_artifact_link"); + }); + + it("should default linkType to Branch when not provided", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; + mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + const result = await handler({ + action: "add_artifact_link", + workItemId: 1234, + project: "TestProject", + artifactUri: "vstfs:///Git/Ref/project%2Frepo%2FGBmain", + // linkType intentionally omitted — should default to "Branch" + }); + + expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, [expect.objectContaining({ value: expect.objectContaining({ attributes: { name: "Branch" } }) })], 1234, "TestProject"); + + const response = JSON.parse(result.content[0].text); + expect(response.linkType).toBe("Branch"); + expect(response.success).toBe(true); + }); + it("should handle errors when adding artifact link", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; mockWorkItemTrackingApi.updateWorkItem.mockRejectedValue(new Error("API Error")); @@ -3407,7 +3973,7 @@ describe("configureWorkItemTools", () => { linkType: "Branch", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.content[0].text).toBe("Error adding artifact link to work item: API Error"); expect(result.isError).toBe(true); @@ -3417,8 +3983,8 @@ describe("configureWorkItemTools", () => { it("should build Branch URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3433,7 +3999,7 @@ describe("configureWorkItemTools", () => { branchName: "feature/test-branch", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3461,8 +4027,8 @@ describe("configureWorkItemTools", () => { it("should return error for Branch link missing required parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3473,7 +4039,7 @@ describe("configureWorkItemTools", () => { // Missing repositoryId and branchName }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("For 'Branch' links, 'projectId', 'repositoryId', and 'branchName' are required."); @@ -3482,8 +4048,8 @@ describe("configureWorkItemTools", () => { it("should build Fixed in Commit URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3499,7 +4065,7 @@ describe("configureWorkItemTools", () => { comment: "Fixed in this commit", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3528,8 +4094,8 @@ describe("configureWorkItemTools", () => { it("should return error for Fixed in Commit link missing required parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3540,7 +4106,7 @@ describe("configureWorkItemTools", () => { // Missing repositoryId and commitId }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("For 'Fixed in Commit' links, 'projectId', 'repositoryId', and 'commitId' are required."); @@ -3549,8 +4115,8 @@ describe("configureWorkItemTools", () => { it("should build Pull Request URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3565,7 +4131,7 @@ describe("configureWorkItemTools", () => { pullRequestId: 42, }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3593,8 +4159,8 @@ describe("configureWorkItemTools", () => { it("should return error for Pull Request link missing required parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3606,7 +4172,7 @@ describe("configureWorkItemTools", () => { // Missing pullRequestId }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("For 'Pull Request' links, 'projectId', 'repositoryId', and 'pullRequestId' are required."); @@ -3615,8 +4181,8 @@ describe("configureWorkItemTools", () => { it("should build Build URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3629,7 +4195,7 @@ describe("configureWorkItemTools", () => { buildId: 123, }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3657,8 +4223,8 @@ describe("configureWorkItemTools", () => { it("should build Found in build URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3671,7 +4237,7 @@ describe("configureWorkItemTools", () => { buildId: 456, }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3699,8 +4265,8 @@ describe("configureWorkItemTools", () => { it("should build Integrated in build URI from components", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3713,7 +4279,7 @@ describe("configureWorkItemTools", () => { buildId: 789, }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3741,8 +4307,8 @@ describe("configureWorkItemTools", () => { it("should return error for build link types missing buildId", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3752,7 +4318,7 @@ describe("configureWorkItemTools", () => { // Missing buildId }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("For 'Build' links, 'buildId' is required."); @@ -3761,8 +4327,8 @@ describe("configureWorkItemTools", () => { it("should build Wiki URI from components and use 'Wiki Page' as attribute name", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3777,7 +4343,7 @@ describe("configureWorkItemTools", () => { pagePath: "/Home/What-is-Contoso", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3806,8 +4372,8 @@ describe("configureWorkItemTools", () => { it("should build Wiki URI from components when pagePath has no leading slash", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; @@ -3822,7 +4388,7 @@ describe("configureWorkItemTools", () => { pagePath: "Home/What-is-Contoso", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( {}, @@ -3850,8 +4416,8 @@ describe("configureWorkItemTools", () => { it("should return error for Wiki link missing required parameters", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3862,7 +4428,7 @@ describe("configureWorkItemTools", () => { // Missing wikiId and pagePath }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("For 'Wiki' links, 'projectId', 'wikiId', and 'pagePath' are required."); @@ -3871,8 +4437,8 @@ describe("configureWorkItemTools", () => { it("should return error for unsupported link type in URI building", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; const params = { @@ -3881,7 +4447,7 @@ describe("configureWorkItemTools", () => { linkType: "Model Link", // Unsupported link type for URI building }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("URI building from components is not supported for link type 'Model Link'. Please provide the full 'artifactUri' instead."); @@ -3890,8 +4456,8 @@ describe("configureWorkItemTools", () => { it("should handle null response from updateWorkItem (line 1000 coverage)", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); const [, , , handler] = call; // Mock updateWorkItem to return null @@ -3904,11 +4470,112 @@ describe("configureWorkItemTools", () => { linkType: "Branch", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Work item update failed"); }); + + it("should build Wiki URI from pageId by fetching the page path", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const mockWorkItem = { id: 1234, fields: { "System.Title": "Test Item" } }; + mockWorkItemTrackingApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ path: "/Home/What-is-Contoso" }), + }); + + const result = await handler({ + action: "add_artifact_link", + workItemId: 1234, + project: "TestProject", + linkType: "Wiki", + projectId: "project-guid", + wikiId: "wiki-guid", + pageId: 98, + }); + + expect(global.fetch).toHaveBeenCalledWith( + "https://dev.azure.com/contoso/TestProject/_apis/wiki/wikis/wiki-guid/pages/98?api-version=7.1", + expect.objectContaining({ headers: expect.objectContaining({ Authorization: "Bearer fake-token" }) }) + ); + + expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith( + {}, + [ + { + op: "add", + path: "/relations/-", + value: { + rel: "ArtifactLink", + url: "vstfs:///Wiki/WikiPage/project-guid%2Fwiki-guid%2FHome%2FWhat-is-Contoso", + attributes: { name: "Wiki Page" }, + }, + }, + ], + 1234, + "TestProject" + ); + + const response = JSON.parse(result.content[0].text); + expect(response.success).toBe(true); + }); + + it("should return error when pageId fetch fails", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + mockConnection.serverUrl = "https://dev.azure.com/contoso"; + (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); + + global.fetch = jest.fn().mockResolvedValue({ ok: false, statusText: "Not Found" }); + + const result = await handler({ + action: "add_artifact_link", + workItemId: 1234, + project: "TestProject", + linkType: "Wiki", + projectId: "project-guid", + wikiId: "wiki-guid", + pageId: 999, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Failed to look up wiki page ID 999: Not Found"); + }); + + it("should return error for Wiki link when neither pageId nor pagePath is provided", async () => { + configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write tool not registered"); + const [, , , handler] = call; + + const result = await handler({ + action: "add_artifact_link", + workItemId: 1234, + project: "TestProject", + linkType: "Wiki", + projectId: "project-guid", + wikiId: "wiki-guid", + // neither pageId nor pagePath provided + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("For 'Wiki' links, 'pageId' or 'pagePath' is required."); + }); }); }); @@ -3923,8 +4590,8 @@ describe("configureWorkItemTools", () => { it("should return attachment content as a base64 image resource", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeImageData = Buffer.from("fake-png-bytes"); @@ -3936,7 +4603,7 @@ describe("configureWorkItemTools", () => { fileName: "screenshot.png", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(mockWorkItemTrackingApi.getAttachmentContent).toHaveBeenCalledWith(params.attachmentId, params.fileName, params.project); @@ -3950,18 +4617,14 @@ describe("configureWorkItemTools", () => { it("should use application/octet-stream for an unknown file extension", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeData = Buffer.from("binary-data"); mockWorkItemTrackingApi.getAttachmentContent.mockResolvedValue(makeReadableStream(fakeData)); - const result = await handler({ - project: "TestProject", - attachmentId: "12341234-1234-1234-1234-123412341234", - fileName: "data.xyz", - }); + const result = await handler({ action: "add_artifact_link", project: "TestProject", attachmentId: "12341234-1234-1234-1234-123412341234", fileName: "data.xyz" }); expect(result.content[0].resource.mimeType).toBe("application/octet-stream"); }); @@ -3969,17 +4632,14 @@ describe("configureWorkItemTools", () => { it("should use application/octet-stream when fileName is omitted", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeData = Buffer.from("binary-data"); mockWorkItemTrackingApi.getAttachmentContent.mockResolvedValue(makeReadableStream(fakeData)); - const result = await handler({ - project: "TestProject", - attachmentId: "12341234-1234-1234-1234-123412341234", - }); + const result = await handler({ action: "add_artifact_link", project: "TestProject", attachmentId: "12341234-1234-1234-1234-123412341234" }); expect(result.content[0].resource.mimeType).toBe("application/octet-stream"); }); @@ -3987,17 +4647,13 @@ describe("configureWorkItemTools", () => { it("should return an error when getAttachmentContent rejects", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; mockWorkItemTrackingApi.getAttachmentContent.mockRejectedValue(new Error("Not found")); - const result = await handler({ - project: "TestProject", - attachmentId: "12341234-1234-1234-1234-123412341234", - fileName: "screenshot.png", - }); + const result = await handler({ action: "add_artifact_link", project: "TestProject", attachmentId: "12341234-1234-1234-1234-123412341234", fileName: "screenshot.png" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work item attachment: Not found"); @@ -4006,8 +4662,8 @@ describe("configureWorkItemTools", () => { it("should save file to disk and return path text when savePath is provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeData = Buffer.from("fake-png-bytes"); @@ -4021,7 +4677,7 @@ describe("configureWorkItemTools", () => { savePath: "downloads/attachments", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); const expectedPath = path.join("downloads/attachments", "screenshot.png"); expect(writeFileSyncMock).toHaveBeenCalledWith(expectedPath, fakeData); @@ -4034,8 +4690,8 @@ describe("configureWorkItemTools", () => { it("should use attachmentId as filename when savePath is provided but fileName is omitted", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeData = Buffer.from("binary-data"); @@ -4049,7 +4705,7 @@ describe("configureWorkItemTools", () => { savePath: "downloads/attachments", }; - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); const expectedPath = path.join("downloads/attachments", attachmentId); expect(writeFileSyncMock).toHaveBeenCalledWith(expectedPath, fakeData); @@ -4061,8 +4717,8 @@ describe("configureWorkItemTools", () => { it("should throw an error if the file already exists at the savePath", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const fakeData = Buffer.from("fake-png-bytes"); @@ -4077,7 +4733,7 @@ describe("configureWorkItemTools", () => { }; const expectedPath = path.join("downloads/attachments", "screenshot.png"); - const result = await handler(params); + const result = await handler({ action: "add_artifact_link", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe(`Error retrieving work item attachment: File already exists: ${expectedPath}`); @@ -4088,19 +4744,15 @@ describe("configureWorkItemTools", () => { it("should return text content for markdown files when savePath is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const markdownContent = "# Hello\n\nThis is a markdown file."; const fakeData = Buffer.from(markdownContent, "utf-8"); mockWorkItemTrackingApi.getAttachmentContent.mockResolvedValue(makeReadableStream(fakeData)); - const result = await handler({ - project: "TestProject", - attachmentId: "12341234-1234-1234-1234-123412341234", - fileName: "notes.md", - }); + const result = await handler({ action: "add_artifact_link", project: "TestProject", attachmentId: "12341234-1234-1234-1234-123412341234", fileName: "notes.md" }); expect(result.content[0].type).toBe("text"); expect(result.content[0].text).toBe(markdownContent); @@ -4109,19 +4761,15 @@ describe("configureWorkItemTools", () => { it("should return text content for plain text files when savePath is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const textContent = "Hello, world!"; const fakeData = Buffer.from(textContent, "utf-8"); mockWorkItemTrackingApi.getAttachmentContent.mockResolvedValue(makeReadableStream(fakeData)); - const result = await handler({ - project: "TestProject", - attachmentId: "12341234-1234-1234-1234-123412341234", - fileName: "readme.txt", - }); + const result = await handler({ action: "add_artifact_link", project: "TestProject", attachmentId: "12341234-1234-1234-1234-123412341234", fileName: "readme.txt" }); expect(result.content[0].type).toBe("text"); expect(result.content[0].text).toBe(textContent); @@ -4130,8 +4778,8 @@ describe("configureWorkItemTools", () => { it("should reject savePath with a Unix absolute path", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const params = { @@ -4141,15 +4789,15 @@ describe("configureWorkItemTools", () => { savePath: "/tmp/attachments", }; - await expect(handler(params)).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); + await expect(handler({ ...params })).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); expect(connectionProvider).not.toHaveBeenCalled(); }); it("should reject savePath with a Windows absolute path", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const params = { @@ -4159,15 +4807,15 @@ describe("configureWorkItemTools", () => { savePath: "C:\\temp\\attachments", }; - await expect(handler(params)).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); + await expect(handler({ ...params })).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); expect(connectionProvider).not.toHaveBeenCalled(); }); it("should reject savePath with path traversal segments", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const params = { @@ -4177,15 +4825,15 @@ describe("configureWorkItemTools", () => { savePath: "../../etc", }; - await expect(handler(params)).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); + await expect(handler({ ...params })).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); expect(connectionProvider).not.toHaveBeenCalled(); }); it("should reject savePath with a Windows drive-relative path", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const params = { @@ -4195,15 +4843,15 @@ describe("configureWorkItemTools", () => { savePath: "D:attachments", }; - await expect(handler(params)).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); + await expect(handler({ ...params })).rejects.toThrow("Invalid savePath: absolute paths and path traversals are not allowed."); expect(connectionProvider).not.toHaveBeenCalled(); }); it("should reject fileName with path traversal segments", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment tool not registered"); const [, , , handler] = call; const params = { @@ -4213,7 +4861,7 @@ describe("configureWorkItemTools", () => { savePath: "downloads", }; - await expect(handler(params)).rejects.toThrow("Invalid fileName: path traversal is not allowed."); + await expect(handler({ ...params })).rejects.toThrow("Invalid fileName: path traversal is not allowed."); expect(connectionProvider).not.toHaveBeenCalled(); }); }); @@ -4222,8 +4870,8 @@ describe("configureWorkItemTools", () => { it("should call queryByWiql with correct params when project is provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql"); - if (!call) throw new Error("wit_query_by_wiql tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValue(_mockWiqlQueryResults); @@ -4236,7 +4884,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "wiql", ...params }); expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: params.project, team: undefined }, undefined, 50); expect(result.content[0].text).toContain("UNTRUSTED"); @@ -4246,8 +4894,8 @@ describe("configureWorkItemTools", () => { it("should call queryByWiql with all optional params when provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql"); - if (!call) throw new Error("wit_query_by_wiql tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValue(_mockWiqlQueryResults); @@ -4260,7 +4908,7 @@ describe("configureWorkItemTools", () => { top: 100, }; - const result = await handler(params); + const result = await handler({ action: "wiql", ...params }); expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: "Contoso", team: "Fabrikam" }, true, 100); expect(result.content[0].text).toContain("UNTRUSTED"); @@ -4270,8 +4918,8 @@ describe("configureWorkItemTools", () => { it("should elicit project when project is not provided and user accepts", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql"); - if (!call) throw new Error("wit_query_by_wiql tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; const mockCoreApi = { getProjects: jest.fn().mockResolvedValue([{ id: "proj-1", name: "Contoso" }]) }; @@ -4292,7 +4940,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "wiql", ...params }); expect((server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput).toHaveBeenCalled(); expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith({ query: params.wiql }, { project: "Contoso", team: undefined }, undefined, 50); @@ -4303,8 +4951,8 @@ describe("configureWorkItemTools", () => { it("should return cancellation message when user declines project elicitation", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql"); - if (!call) throw new Error("wit_query_by_wiql tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; const mockCoreApi = { getProjects: jest.fn().mockResolvedValue([{ id: "proj-1", name: "Contoso" }]) }; @@ -4322,7 +4970,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "wiql", ...params }); expect(mockWorkItemTrackingApi.queryByWiql).not.toHaveBeenCalled(); expect(result.content[0].text).toBe("Project selection cancelled."); @@ -4331,8 +4979,8 @@ describe("configureWorkItemTools", () => { it("should return an error when queryByWiql throws", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query_by_wiql"); - if (!call) throw new Error("wit_query_by_wiql tool not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query tool not registered"); const [, , , handler] = call; (mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockRejectedValue(new Error("WIQL syntax error")); @@ -4345,7 +4993,7 @@ describe("configureWorkItemTools", () => { top: 50, }; - const result = await handler(params); + const result = await handler({ action: "wiql", ...params }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error executing WIQL query: WIQL syntax error"); @@ -4366,236 +5014,236 @@ describe("configureWorkItemTools", () => { it("list_backlogs: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ team: "Fabrikam" }); + const result = await handler({ action: "list", team: "Fabrikam" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("list_backlogs: should use elicited project and return elicitation response when team selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ project: "Contoso" }); + const result = await handler({ action: "list", project: "Contoso" }); expect(result.content[0].text).toBe("Team selection cancelled."); }); it("list_backlog_work_items: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ team: "Fabrikam", backlogId: "Microsoft.FeatureCategory" }); + const result = await handler({ action: "list_work_items", team: "Fabrikam", backlogId: "Microsoft.FeatureCategory" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("list_backlog_work_items: should return elicitation response when team selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ project: "Contoso", backlogId: "Microsoft.FeatureCategory" }); + const result = await handler({ action: "list_work_items", project: "Contoso", backlogId: "Microsoft.FeatureCategory" }); expect(result.content[0].text).toBe("Team selection cancelled."); }); it("my_work_items: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); - if (!call) throw new Error("wit_my_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ type: "assignedtome", top: 50, includeCompleted: false }); + const result = await handler({ action: "my", type: "assignedtome", top: 50, includeCompleted: false }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_work_items_batch_by_ids: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ ids: [1, 2] }); + const result = await handler({ action: "get_batch", ids: [1, 2] }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_work_item: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); - if (!call) throw new Error("wit_get_work_item not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ id: 1 }); + const result = await handler({ action: "get", id: 1 }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("list_work_item_comments: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); - if (!call) throw new Error("wit_list_work_item_comments not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemId: 1, top: 10 }); + const result = await handler({ action: "list_comments", workItemId: 1, top: 10 }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("add_work_item_comment: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); - if (!call) throw new Error("wit_add_work_item_comment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemId: 1, comment: "test comment" }); + const result = await handler({ action: "add", workItemId: 1, comment: "test comment" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("update_work_item_comment: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item_comment"); - if (!call) throw new Error("wit_update_work_item_comment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemId: 1, commentId: 1, text: "updated text" }); + const result = await handler({ action: "update", workItemId: 1, commentId: 1, text: "updated text" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("list_work_item_revisions: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); - if (!call) throw new Error("wit_list_work_item_revisions not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemId: 1, top: 10 }); + const result = await handler({ action: "list_revisions", workItemId: 1, top: 10 }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("add_child_work_items: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ parentId: 1, workItemType: "Task", items: [{ title: "Child", description: "Desc" }] }); + const result = await handler({ action: "add_child", parentId: 1, workItemType: "Task", items: [{ title: "Child", description: "Desc" }] }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_work_items_for_iteration: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); - if (!call) throw new Error("wit_get_work_items_for_iteration not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ iterationId: "iter-1" }); + const result = await handler({ action: "list_for_iteration", iterationId: "iter-1" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_work_item_type: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); - if (!call) throw new Error("wit_get_work_item_type not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemType: "Bug" }); + const result = await handler({ action: "get_type", workItemType: "Bug" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("create_work_item: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); - if (!call) throw new Error("wit_create_work_item not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemType: "Task", fields: [{ name: "System.Title", value: "Test" }] }); + const result = await handler({ action: "create", workItemType: "Task", fields: [{ name: "System.Title", value: "Test" }] }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_query: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); - if (!call) throw new Error("wit_get_query not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ query: "some-query-id", depth: 0, includeDeleted: false, useIsoDateFormat: false }); + const result = await handler({ action: "get", query: "some-query-id", depth: 0, includeDeleted: false, useIsoDateFormat: false }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("work_items_link: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ updates: [{ id: 1, linkToId: 2, type: "related" }] }); + const result = await handler({ action: "link", updates: [{ id: 1, linkToId: 2, type: "related" }] }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("work_item_unlink: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ id: 1, type: "related" }); + const result = await handler({ action: "unlink", id: 1, type: "related" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("add_artifact_link: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupElicitMocks("decline"); - const result = await handler({ workItemId: 1, artifactUri: "vstfs:///Git/Ref/test", linkType: "Branch" }); + const result = await handler({ action: "add_artifact_link", workItemId: 1, artifactUri: "vstfs:///Git/Ref/test", linkType: "Branch" }); expect(result.content[0].text).toBe("Project selection cancelled."); }); it("get_work_item_attachment: should return elicitation response when project selection is declined", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment not registered"); const [, , , handler] = call; setupElicitMocks("decline"); @@ -4619,112 +5267,112 @@ describe("configureWorkItemTools", () => { it("list_backlogs: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getBacklogs as jest.Mock).mockResolvedValue([]); - await handler({ team: "Fabrikam" }); + await handler({ action: "list", team: "Fabrikam" }); expect(mockWorkApi.getBacklogs).toHaveBeenCalledWith({ project: "Contoso", team: "Fabrikam" }); }); it("list_backlogs: should use elicited team when team is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlogs"); - if (!call) throw new Error("wit_list_backlogs not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getBacklogs as jest.Mock).mockResolvedValue([]); - await handler({ project: "Contoso" }); + await handler({ action: "list", project: "Contoso" }); expect(mockWorkApi.getBacklogs).toHaveBeenCalledWith({ project: "Contoso", team: "Fabrikam" }); }); it("list_backlog_work_items: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockResolvedValue([]); - await handler({ team: "Fabrikam", backlogId: "Microsoft.FeatureCategory" }); + await handler({ action: "list_work_items", team: "Fabrikam", backlogId: "Microsoft.FeatureCategory" }); expect(mockWorkApi.getBacklogLevelWorkItems).toHaveBeenCalledWith({ project: "Contoso", team: "Fabrikam" }, "Microsoft.FeatureCategory"); }); it("list_backlog_work_items: should use elicited team when team is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_backlog_work_items"); - if (!call) throw new Error("wit_list_backlog_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_backlog"); + if (!call) throw new Error("wit_backlog not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getBacklogLevelWorkItems as jest.Mock).mockResolvedValue([]); - await handler({ project: "Contoso", backlogId: "Microsoft.FeatureCategory" }); + await handler({ action: "list_work_items", project: "Contoso", backlogId: "Microsoft.FeatureCategory" }); expect(mockWorkApi.getBacklogLevelWorkItems).toHaveBeenCalledWith({ project: "Contoso", team: "Fabrikam" }, "Microsoft.FeatureCategory"); }); it("my_work_items: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_my_work_items"); - if (!call) throw new Error("wit_my_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockResolvedValue([]); - await handler({ type: "assignedtome", top: 10, includeCompleted: false }); + await handler({ action: "my", type: "assignedtome", top: 10, includeCompleted: false }); expect(mockWorkApi.getPredefinedQueryResults).toHaveBeenCalledWith("Contoso", "assignedtome", 10, false); }); it("get_work_items_batch_by_ids: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_batch_by_ids"); - if (!call) throw new Error("wit_get_work_items_batch_by_ids not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getWorkItemsBatch as jest.Mock).mockResolvedValue([]); - await handler({ ids: [1, 2] }); + await handler({ action: "get_batch", ids: [1, 2] }); expect(mockWorkItemTrackingApi.getWorkItemsBatch).toHaveBeenCalledWith({ ids: [1, 2], fields: expect.any(Array) }, "Contoso"); }); it("get_work_item: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item"); - if (!call) throw new Error("wit_get_work_item not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue({ id: 1 }); - await handler({ id: 1 }); + await handler({ action: "get", id: 1 }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(1, undefined, undefined, undefined, "Contoso"); }); it("list_work_item_comments: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_comments"); - if (!call) throw new Error("wit_list_work_item_comments not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getComments as jest.Mock).mockResolvedValue([]); - await handler({ workItemId: 1, top: 10 }); + await handler({ action: "list_comments", workItemId: 1, top: 10 }); expect(mockWorkItemTrackingApi.getComments).toHaveBeenCalledWith("Contoso", 1, 10); }); it("add_work_item_comment: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_work_item_comment"); - if (!call) throw new Error("wit_add_work_item_comment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write not registered"); const [, , , handler] = call; setupAcceptMocks(); @@ -4732,15 +5380,15 @@ describe("configureWorkItemTools", () => { (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("{}") }); - await handler({ workItemId: 1, comment: "test comment" }); + await handler({ action: "add", workItemId: 1, text: "test comment" }); const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(calledUrl).toContain("Contoso"); }); it("update_work_item_comment: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item_comment"); - if (!call) throw new Error("wit_update_work_item_comment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_comment_write"); + if (!call) throw new Error("wit_work_item_comment_write not registered"); const [, , , handler] = call; setupAcceptMocks(); @@ -4748,28 +5396,28 @@ describe("configureWorkItemTools", () => { (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("{}") }); - await handler({ workItemId: 1, commentId: 1, text: "updated" }); + await handler({ action: "update", workItemId: 1, commentId: 1, text: "updated" }); const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(calledUrl).toContain("Contoso"); }); it("list_work_item_revisions: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_list_work_item_revisions"); - if (!call) throw new Error("wit_list_work_item_revisions not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue([]); - await handler({ workItemId: 1, top: 10 }); + await handler({ action: "list_revisions", workItemId: 1, top: 10 }); expect(mockWorkItemTrackingApi.getRevisions).toHaveBeenCalledWith(1, 10, undefined, undefined, "Contoso"); }); it("add_child_work_items: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_child_work_items"); - if (!call) throw new Error("wit_add_child_work_items not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write not registered"); const [, , , handler] = call; setupAcceptMocks(); @@ -4777,67 +5425,67 @@ describe("configureWorkItemTools", () => { (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ responses: [] }) }); - await handler({ parentId: 1, workItemType: "Task", items: [{ title: "Child", description: "Desc" }] }); + await handler({ action: "add_child", parentId: 1, workItemType: "Task", items: [{ title: "Child", description: "Desc" }] }); const calledBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body); expect(calledBody[0].uri).toContain("Contoso"); }); it("get_work_items_for_iteration: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_items_for_iteration"); - if (!call) throw new Error("wit_get_work_items_for_iteration not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkApi.getIterationWorkItems as jest.Mock).mockResolvedValue([]); - await handler({ iterationId: "iter-1" }); + await handler({ action: "list_for_iteration", iterationId: "iter-1" }); expect(mockWorkApi.getIterationWorkItems).toHaveBeenCalledWith({ project: "Contoso", team: undefined }, "iter-1"); }); it("get_work_item_type: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_type"); - if (!call) throw new Error("wit_get_work_item_type not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item"); + if (!call) throw new Error("wit_work_item not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockResolvedValue({}); - await handler({ workItemType: "Bug" }); + await handler({ action: "get_type", workItemType: "Bug" }); expect(mockWorkItemTrackingApi.getWorkItemType).toHaveBeenCalledWith("Contoso", "Bug"); }); it("create_work_item: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_create_work_item"); - if (!call) throw new Error("wit_create_work_item not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); + if (!call) throw new Error("wit_work_item_write not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.createWorkItem as jest.Mock).mockResolvedValue({ id: 1 }); - await handler({ workItemType: "Task", fields: [{ name: "System.Title", value: "Test" }] }); + await handler({ action: "create", workItemType: "Task", fields: [{ name: "System.Title", value: "Test" }] }); expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledWith(null, expect.any(Array), "Contoso", "Task"); }); it("get_query: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_query"); - if (!call) throw new Error("wit_get_query not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_query"); + if (!call) throw new Error("wit_query not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getQuery as jest.Mock).mockResolvedValue({}); - await handler({ query: "some-query-id", depth: 0, includeDeleted: false, useIsoDateFormat: false }); + await handler({ action: "get", query: "some-query-id", depth: 0, includeDeleted: false, useIsoDateFormat: false }); expect(mockWorkItemTrackingApi.getQuery).toHaveBeenCalledWith("Contoso", "some-query-id", undefined, 0, false, false); }); it("work_items_link: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_items_link"); - if (!call) throw new Error("wit_work_items_link not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupAcceptMocks(); @@ -4845,41 +5493,41 @@ describe("configureWorkItemTools", () => { (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve([]) }); - await handler({ updates: [{ id: 1, linkToId: 2, type: "related" }] }); + await handler({ action: "link", updates: [{ id: 1, linkToId: 2, type: "related" }] }); expect(global.fetch).toHaveBeenCalled(); }); it("work_item_unlink: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_unlink"); - if (!call) throw new Error("wit_work_item_unlink not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.getWorkItem as jest.Mock).mockResolvedValue({ id: 1, relations: [] }); (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 1 }); - await handler({ id: 1, type: "related" }); + await handler({ action: "unlink", id: 1, type: "related" }); expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith(1, undefined, undefined, 1, "Contoso"); }); it("add_artifact_link: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_add_artifact_link"); - if (!call) throw new Error("wit_add_artifact_link not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_link_write"); + if (!call) throw new Error("wit_work_item_link_write not registered"); const [, , , handler] = call; setupAcceptMocks(); (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockResolvedValue({ id: 1 }); - await handler({ workItemId: 1, artifactUri: "vstfs:///Git/Ref/test", linkType: "Branch" }); + await handler({ action: "add_artifact_link", workItemId: 1, artifactUri: "vstfs:///Git/Ref/test", linkType: "Branch" }); expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledWith({}, expect.any(Array), 1, "Contoso"); }); it("get_work_item_attachment: should use elicited project when project is not provided", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_get_work_item_attachment"); - if (!call) throw new Error("wit_get_work_item_attachment not registered"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_attachment"); + if (!call) throw new Error("wit_work_item_attachment not registered"); const [, , , handler] = call; setupAcceptMocks(); @@ -4896,7 +5544,7 @@ describe("configureWorkItemTools", () => { describe("update_work_item schema transform coverage", () => { it("should apply lowercase transform to the op field via Zod schema", async () => { configureWorkItemTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_update_work_item"); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wit_work_item_write"); if (!call) throw new Error("wit_update_work_item not registered"); const [, , schemaShape] = call; @@ -4904,6 +5552,7 @@ describe("configureWorkItemTools", () => { const { z } = await import("zod"); const fullSchema = z.object(schemaShape as Parameters[0]); const parsed = fullSchema.parse({ + action: "update", id: 1, updates: [{ op: "Replace", path: "/fields/System.Title", value: "test" }], }); @@ -4923,173 +5572,173 @@ describe("configureWorkItemTools", () => { } it("list_backlogs: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_list_backlogs"); + const handler = getHandler("wit_backlog"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", team: "T" }); + const result = await handler({ action: "list", project: "P", team: "T" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error listing backlogs: Unknown error occurred"); }); it("list_backlog_work_items: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_list_backlog_work_items"); + const handler = getHandler("wit_backlog"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", team: "T", backlogId: "B" }); + const result = await handler({ action: "list_work_items", project: "P", team: "T", backlogId: "B" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error listing backlog work items: Unknown error occurred"); }); it("my_work_items: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_my_work_items"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", type: "assignedtome", top: 10, includeCompleted: false }); + const result = await handler({ action: "my", project: "P", type: "assignedtome", top: 10, includeCompleted: false }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work items: Unknown error occurred"); }); it("get_work_items_batch_by_ids: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_work_items_batch_by_ids"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", ids: [1] }); + const result = await handler({ action: "get_batch", project: "P", ids: [1] }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work items batch: Unknown error occurred"); }); it("get_work_item: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_work_item"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ id: 1, project: "P" }); + const result = await handler({ action: "get", id: 1, project: "P" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work item: Unknown error occurred"); }); it("list_work_item_comments: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_list_work_item_comments"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", workItemId: 1, top: 10 }); + const result = await handler({ action: "list_comments", project: "P", workItemId: 1, top: 10 }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error listing work item comments: Unknown error occurred"); }); it("add_work_item_comment: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_add_work_item_comment"); + const handler = getHandler("wit_work_item_comment_write"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", workItemId: 1, comment: "test" }); + const result = await handler({ action: "add", project: "P", workItemId: 1, comment: "test" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error adding work item comment: Unknown error occurred"); }); it("update_work_item_comment: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_update_work_item_comment"); + const handler = getHandler("wit_work_item_comment_write"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", workItemId: 1, commentId: 1, text: "updated" }); + const result = await handler({ action: "update", project: "P", workItemId: 1, commentId: 1, text: "updated" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error updating work item comment: Unknown error occurred"); }); it("update_work_item_comment: should use format=0 when format is markdown", async () => { - const handler = getHandler("wit_update_work_item_comment"); + const handler = getHandler("wit_work_item_comment_write"); mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("{}") }); - await handler({ project: "P", workItemId: 1, commentId: 1, text: "updated", format: "Markdown" }); + await handler({ action: "update", project: "P", workItemId: 1, commentId: 1, text: "updated", format: "Markdown" }); const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(calledUrl).toContain("format=0"); }); it("update_work_item_comment: should use format=1 when format is Html", async () => { - const handler = getHandler("wit_update_work_item_comment"); + const handler = getHandler("wit_work_item_comment_write"); mockConnection.serverUrl = "https://dev.azure.com/contoso"; (tokenProvider as jest.Mock).mockResolvedValue("fake-token"); global.fetch = jest.fn().mockResolvedValue({ ok: true, text: () => Promise.resolve("{}") }); - await handler({ project: "P", workItemId: 1, commentId: 1, text: "updated", format: "Html" }); + await handler({ action: "update", project: "P", workItemId: 1, commentId: 1, text: "updated", format: "Html" }); const calledUrl = (global.fetch as jest.Mock).mock.calls[0][0] as string; expect(calledUrl).toContain("format=1"); }); it("list_work_item_revisions: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_list_work_item_revisions"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", workItemId: 1, top: 10 }); + const result = await handler({ action: "list_revisions", project: "P", workItemId: 1, top: 10 }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error listing work item revisions: Unknown error occurred"); }); it("list_work_item_revisions: should handle null revisions without errors", async () => { - const handler = getHandler("wit_list_work_item_revisions"); + const handler = getHandler("wit_work_item"); (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue(null); - const result = await handler({ project: "P", workItemId: 1, top: 10 }); + const result = await handler({ action: "list_revisions", project: "P", workItemId: 1, top: 10 }); expect(result.content[0].text).toBe(JSON.stringify(null, null, 2)); }); it("get_work_items_for_iteration: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_work_items_for_iteration"); + const handler = getHandler("wit_work_item"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", iterationId: "iter-1" }); + const result = await handler({ action: "list_for_iteration", project: "P", iterationId: "iter-1" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work items for iteration: Unknown error occurred"); }); it("update_work_item: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_update_work_item"); + const handler = getHandler("wit_work_item_write"); (mockWorkItemTrackingApi.updateWorkItem as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ id: 1, updates: [{ op: "add", path: "/fields/System.Title", value: "T" }] }); + const result = await handler({ action: "update", id: 1, updates: [{ op: "add", path: "/fields/System.Title", value: "T" }] }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error updating work item: Unknown error occurred"); }); it("get_work_item_type: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_work_item_type"); + const handler = getHandler("wit_work_item"); (mockWorkItemTrackingApi.getWorkItemType as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", workItemType: "Bug" }); + const result = await handler({ action: "get_type", project: "P", workItemType: "Bug" }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving work item type: Unknown error occurred"); }); it("get_query: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_query"); + const handler = getHandler("wit_query"); (mockWorkItemTrackingApi.getQuery as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", query: "q", depth: 0, includeDeleted: false, useIsoDateFormat: false }); + const result = await handler({ action: "get", project: "P", query: "q", depth: 0, includeDeleted: false, useIsoDateFormat: false }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving query: Unknown error occurred"); }); it("get_query_results_by_id: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_query_results_by_id"); + const handler = getHandler("wit_query"); (mockWorkItemTrackingApi.queryById as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ id: "q-id", project: "P", top: 10 }); + const result = await handler({ action: "get_results", id: "q-id", project: "P", top: 10 }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error retrieving query results: Unknown error occurred"); }); it("get_query_results_by_id: should handle null workItems in ids mode", async () => { - const handler = getHandler("wit_get_query_results_by_id"); + const handler = getHandler("wit_query"); (mockWorkItemTrackingApi.queryById as jest.Mock).mockResolvedValue({ workItems: null }); - const result = await handler({ id: "q-id", project: "P", responseType: "ids", top: 50 }); + const result = await handler({ action: "get_results", id: "q-id", project: "P", responseType: "ids", top: 50 }); const parsed = JSON.parse(result.content[0].text); expect(parsed.ids).toEqual([]); expect(parsed.count).toBe(0); }); it("update_work_items_batch: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_update_work_items_batch"); + const handler = getHandler("wit_work_item_write"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ updates: [{ op: "replace", id: 1, path: "/fields/System.Title", value: "T" }] }); + const result = await handler({ action: "update_batch", updates: [{ op: "replace", id: 1, path: "/fields/System.Title", value: "T" }] }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error updating work items in batch: Unknown error occurred"); }); it("work_items_link: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_work_items_link"); + const handler = getHandler("wit_work_item_link_write"); (connectionProvider as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ project: "P", updates: [{ id: 1, linkToId: 2, type: "related" }] }); + const result = await handler({ action: "link", project: "P", updates: [{ id: 1, linkToId: 2, type: "related" }] }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error linking work items: Unknown error occurred"); }); it("get_work_item_attachment: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_get_work_item_attachment"); + const handler = getHandler("wit_work_item_attachment"); (mockWorkItemTrackingApi.getAttachmentContent as jest.Mock).mockRejectedValue("string error"); const result = await handler({ project: "P", attachmentId: "att-id" }); expect(result.isError).toBe(true); @@ -5097,22 +5746,256 @@ describe("configureWorkItemTools", () => { }); it("query_by_wiql: should return unknown error message for non-Error throws", async () => { - const handler = getHandler("wit_query_by_wiql"); + const handler = getHandler("wit_query"); (mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockRejectedValue("string error"); - const result = await handler({ wiql: "SELECT [System.Id] FROM WorkItems", project: "P", top: 50 }); + const result = await handler({ action: "wiql", wiql: "SELECT [System.Id] FROM WorkItems", project: "P", top: 50 }); expect(result.isError).toBe(true); expect(result.content[0].text).toBe("Error executing WIQL query: Unknown error occurred"); }); it("list_work_item_revisions: should handle revision without fields property", async () => { - const handler = getHandler("wit_list_work_item_revisions"); + const handler = getHandler("wit_work_item"); const revisionsWithNoFields = [ { id: 1, rev: 1 }, // no fields property { id: 2, rev: 2, fields: { "System.Title": "Test" } }, ]; (mockWorkItemTrackingApi.getRevisions as jest.Mock).mockResolvedValue(revisionsWithNoFields); - const result = await handler({ project: "P", workItemId: 1, top: 10 }); + const result = await handler({ action: "list_revisions", project: "P", workItemId: 1, top: 10 }); expect(result.content[0].text).toBe(JSON.stringify(revisionsWithNoFields, null, 2)); }); + + it("wit_work_item: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "invalid_action" as string, project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_query: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_query"); + const result = await handler({ action: "invalid_action" as string, project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_backlog: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_backlog"); + const result = await handler({ action: "invalid_action" as string, project: "P", team: "T" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_work_item_write: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_work_item_write: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_work_item_write"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + it("wit_work_item_comment_write: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_work_item_comment_write"); + const result = await handler({ action: "invalid_action" as string, project: "P", workItemId: 1, text: "test" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_work_item_comment_write: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_work_item_comment_write"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + it("wit_work_item_link_write: should return unknown action for unrecognized action", async () => { + const handler = getHandler("wit_work_item_link_write"); + const result = await handler({ action: "invalid_action" as string, project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Unknown action: invalid_action"); + }); + + it("wit_work_item_link_write: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_work_item_link_write"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + // wit_work_item required param guards + it("wit_work_item.get: should return error when id is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "get", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("id is required for get"); + }); + + it("wit_work_item.get_batch: should return error when ids is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "get_batch", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("ids is required for get_batch"); + }); + + it("wit_work_item.list_comments: should return error when workItemId is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "list_comments", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemId is required for list_comments"); + }); + + it("wit_work_item.list_comments: should use default top of 50 when not provided", async () => { + const handler = getHandler("wit_work_item"); + (mockWorkItemTrackingApi.getComments as jest.Mock).mockResolvedValue([]); + await handler({ action: "list_comments", project: "P", workItemId: 1 }); + expect(mockWorkItemTrackingApi.getComments).toHaveBeenCalledWith("P", 1, 50); + }); + + it("wit_work_item.my: should use defaults when type, top and includeCompleted are not provided", async () => { + const handler = getHandler("wit_work_item"); + (mockWorkApi.getPredefinedQueryResults as jest.Mock).mockResolvedValue([]); + await handler({ action: "my", project: "P" }); + expect(mockWorkApi.getPredefinedQueryResults).toHaveBeenCalledWith("P", "assignedtome", 50, false); + }); + + it("wit_work_item.list_revisions: should return error when workItemId is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "list_revisions", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemId is required for list_revisions"); + }); + + it("wit_work_item.list_for_iteration: should return error when iterationId is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "list_for_iteration", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("iterationId is required for list_for_iteration"); + }); + + it("wit_work_item.get_type: should return error when workItemType is missing", async () => { + const handler = getHandler("wit_work_item"); + const result = await handler({ action: "get_type", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemType is required for get_type"); + }); + + it("wit_work_item: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_work_item"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + // wit_query required param guards + it("wit_query.get: should return error when query is missing", async () => { + const handler = getHandler("wit_query"); + const result = await handler({ action: "get", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("query is required for get"); + }); + + it("wit_query.get_results: should return error when id is missing", async () => { + const handler = getHandler("wit_query"); + const result = await handler({ action: "get_results", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("id is required for get_results"); + }); + + it("wit_query.wiql: should return error when wiql is missing", async () => { + const handler = getHandler("wit_query"); + const result = await handler({ action: "wiql", project: "P" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("wiql is required for wiql"); + }); + + it("wit_query: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_query"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + // wit_backlog required param guards + it("wit_backlog.list_work_items: should return error when backlogId is missing", async () => { + const handler = getHandler("wit_backlog"); + const result = await handler({ action: "list_work_items", project: "P", team: "T" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("backlogId is required for list_work_items"); + }); + + it("wit_backlog: should use error fallback when action is unknown and error is thrown", async () => { + const handler = getHandler("wit_backlog"); + (connectionProvider as jest.Mock).mockRejectedValue(new Error("Connection error")); + const result = await handler({ action: "invalid_action" as string }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Connection error"); + }); + + // wit_work_item_write required param guards + it("wit_work_item_write.create: should return error when workItemType is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "create", project: "P", fields: [{ name: "System.Title", value: "T" }] }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemType is required for create"); + }); + + it("wit_work_item_write.create: should return error when fields is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "create", project: "P", workItemType: "Task" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("fields is required for create"); + }); + + it("wit_work_item_write.update: should return error when id is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "update", updates: [{ op: "add", path: "/fields/System.Title", value: "T" }] }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("id is required for update"); + }); + + it("wit_work_item_write.update: should return error when updates is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "update", id: 1 }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("updates is required for update"); + }); + + it("wit_work_item_write.update_batch: should return error when batchUpdates is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "update_batch" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("batchUpdates is required for update_batch"); + }); + + it("wit_work_item_write.add_child: should return error when parentId is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "add_child", project: "P", workItemType: "Task", items: [{ title: "T", description: "D" }] }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("parentId is required for add_child"); + }); + + it("wit_work_item_write.add_child: should return error when workItemType is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "add_child", project: "P", parentId: 1, items: [{ title: "T", description: "D" }] }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("workItemType is required for add_child"); + }); + + it("wit_work_item_write.add_child: should return error when items is missing", async () => { + const handler = getHandler("wit_work_item_write"); + const result = await handler({ action: "add_child", project: "P", parentId: 1, workItemType: "Task" }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("items is required for add_child"); + }); }); });