diff --git a/README.md b/README.md index 50ed228..a80c446 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,6 @@ Easily install the Azure DevOps MCP Server for VS Code or VS Code Insiders: This TypeScript project provides a **local** MCP server for Azure DevOps, enabling you to perform a wide range of Azure DevOps tasks directly from your code editor. -> ๐Ÿšจ **Public Preview:** This project is in public preview. Tools and features may change before general availability. - ## ๐Ÿ“„ Table of Contents 1. [๐Ÿ“บ Overview](#-overview) @@ -83,8 +81,7 @@ Interact with these Azure DevOps services: ### ๐Ÿ“ Repositories - **repo_list_repos_by_project**: Retrieve a list of repositories for a given project. -- **repo_list_pull_requests_by_repo**: Retrieve a list of pull requests for a given repository. -- **repo_list_pull_requests_by_project**: Retrieve a list of pull requests for a given project ID or name. +- **repo_list_pull_requests_by_repo_or_project**: Retrieve a list of pull requests for a given repository or project. - **repo_list_branches_by_repo**: Retrieve a list of branches for a given repository. - **repo_list_my_branches_by_repo**: Retrieve a list of your branches for a given repository ID. - **repo_list_pull_requests_by_commits**: List pull requests associated with commits. @@ -95,7 +92,6 @@ Interact with these Azure DevOps services: - **repo_get_pull_request_by_id**: Get a pull request by its ID. - **repo_create_pull_request**: Create a new pull request. - **repo_create_branch**: Create a new branch in the repository. -- **repo_update_pull_request_status**: Update the status of an existing pull request to active or abandoned. - **repo_update_pull_request**: Update various fields of an existing pull request (title, description, draft status, target branch). - **repo_update_pull_request_reviewers**: Add or remove reviewers for an existing pull request. - **repo_reply_to_comment**: Replies to a specific comment on a pull request. @@ -126,6 +122,7 @@ Interact with these Azure DevOps services: - **testplan_create_test_plan**: Create a new test plan in the project. - **testplan_create_test_case**: Create a new test case work item. +- **testplan_update_test_case_steps**: Update an existing test case work item's steps. - **testplan_add_test_cases_to_suite**: Add existing test cases to a test suite. - **testplan_list_test_plans**: Retrieve a paginated list of test plans from an Azure DevOps project. Allows filtering for active plans and toggling detailed information. - **testplan_list_test_cases**: Get a list of test cases in the test plan. @@ -137,6 +134,7 @@ Interact with these Azure DevOps services: - **wiki_list_wikis**: Retrieve a list of wikis for an organization or project. - **wiki_get_wiki**: Get the wiki by wikiIdentifier. - **wiki_list_pages**: Retrieve a list of wiki pages for a specific wiki and project. +- **wiki_get_page**: Retrieve wiki page metadata by path. - **wiki_get_page_content**: Retrieve wiki page content by wikiIdentifier and path. - **wiki_create_or_update_page**: Create or update wiki pages with full content support. diff --git a/docs/FAQ.md b/docs/FAQ.md index 57993cb..e89f4f6 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -13,3 +13,11 @@ No, you can connect to only one organization at a time. However, you can switch ## Can I set a default project instead of fetching the list every time? Currently, you need to fetch the list of projects so the LLM has context about the project name or ID. We plan to improve this experience in the future by leveraging prompts. In the meantime, you can set a default project name in your `copilot-instructions.md` file. + +## Are PAT's supported? + +Sorry, PAT's are currently not supported in this local MCP Sever. + +## Is there a remote supported version of the MCP Server? + +At this time, only the local version of the MCP Server is supported. diff --git a/docs/GETTINGSTARTED.md b/docs/GETTINGSTARTED.md index dec99b7..69a5e95 100644 --- a/docs/GETTINGSTARTED.md +++ b/docs/GETTINGSTARTED.md @@ -4,7 +4,7 @@ This guide will help you get started with the Azure DevOps MCP Server in differe - [Prerequisites](#-prerequisites) - [Getting started with Visual Studio Code & GitHub Copilot](#๏ธ-visual-studio-code--github-copilot) -- [Getting started with Visual Studio 2022 & GitHub Copilot](#-visual-studio-2022--github-copilot-1) +- [Getting started with Visual Studio 2022 & GitHub Copilot](#%EF%B8%8F-visual-studio-2022--github-copilot) - [Getting started with Claude Code](#-using-mcp-server-with-claude-code) - [Getting started with Claude Desktop](#๏ธ-using-mcp-server-with-claude-desktop) - [Getting started with Cursor](#-using-mcp-server-with-cursor) @@ -139,8 +139,6 @@ Click "Select Tools" and choose the available tools. ### โžก๏ธ Visual Studio 2022 & GitHub Copilot -For the best experience, use Visual Studio Code and GitHub Copilot ๐Ÿ‘†. - #### ๐Ÿงจ Install from Public Feed (Recommended) This installation method is the easiest for all users of Visual Studio 2022. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index c148271..c3c67b6 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -191,3 +191,18 @@ The MCP server may be authenticating with a different tenant than your Azure Dev 4. **When prompted**, enter: - Your Azure DevOps organization name - The tenant ID from step 1 + +## Common Errors + +1. **Incorrect Organization Name Error** + + ``` + Error fetching projects: Failed to find api location for area: Location id: e81700f7-3be2-46de-8624-2eb35882fcaa + ``` + + **Cause:** This occurs when the Azure DevOps organization name is incorrect or doesn't exist. + + **Solution:** Verify that: + - The organization name is spelled correctly (case-sensitive) + - The organization exists and you have access to it + - You're using just the organization name, not the full URL (e.g., use `contoso` not `https://dev.azure.com/contoso`) diff --git a/package-lock.json b/package-lock.json index 6f81986..1496224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@azure-devops/mcp", - "version": "2.2.0", + "version": "2.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@azure-devops/mcp", - "version": "2.2.0", + "version": "2.2.1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@azure/identity": "^4.10.0", - "@modelcontextprotocol/sdk": "1.17.0", + "@modelcontextprotocol/sdk": "1.20.0", "azure-devops-extension-api": "^4.252.0", "azure-devops-extension-sdk": "^4.0.2", "azure-devops-node-api": "^15.1.0", @@ -23,7 +23,7 @@ "mcp-server-azuredevops": "dist/index.js" }, "devDependencies": { - "@modelcontextprotocol/inspector": "^0.16.1", + "@modelcontextprotocol/inspector": "^0.17.0", "@types/jest": "^30.0.0", "@types/node": "^22", "eslint-config-prettier": "10.1.8", @@ -35,8 +35,8 @@ "shx": "^0.4.0", "ts-jest": "^29.4.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.32.1" + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0" } }, "node_modules/@ampproject/remapping": { @@ -142,9 +142,10 @@ } }, "node_modules/@azure/identity": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.10.2.tgz", - "integrity": "sha512-Uth4vz0j+fkXCkbvutChUj03PDCokjbC6Wk9JT8hHEUtpy/EurNKAseb3+gO6Zi9VYBvwt61pgbzn1ovk942Qg==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz", + "integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==", + "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", @@ -1666,9 +1667,9 @@ } }, "node_modules/@modelcontextprotocol/inspector": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-0.16.6.tgz", - "integrity": "sha512-6x6dzTf8MV6z/XIdzr/4EMK4elMn1XUzTJHxczsBePLg1G5VNAM/4g5abNFIB9bzuxJ/1VH8016Vv6S7sj/24Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector/-/inspector-0.17.0.tgz", + "integrity": "sha512-emuP/FGJ25vJJQu1BBwVXlrEirV3cOoUsd+i+cmnmtCoYxNmPOGU6bXPHPk1kYIXh3QWcfrk/ZySxHF9kXpUgQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -1677,11 +1678,12 @@ "cli" ], "dependencies": { - "@modelcontextprotocol/inspector-cli": "^0.16.6", - "@modelcontextprotocol/inspector-client": "^0.16.6", - "@modelcontextprotocol/inspector-server": "^0.16.6", - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/inspector-cli": "^0.17.0", + "@modelcontextprotocol/inspector-client": "^0.17.0", + "@modelcontextprotocol/inspector-server": "^0.17.0", + "@modelcontextprotocol/sdk": "^1.18.0", "concurrently": "^9.2.0", + "node-fetch": "^3.3.2", "open": "^10.2.0", "shell-quote": "^1.8.3", "spawn-rx": "^5.1.2", @@ -1696,13 +1698,13 @@ } }, "node_modules/@modelcontextprotocol/inspector-cli": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-cli/-/inspector-cli-0.16.6.tgz", - "integrity": "sha512-28RAaGoN9XgKYvl8kOo9wTHBrLp5Th+biTt5mNGUzowMdcoG/FpI8mHROIhcgDyp+kj0SYR5fmwcb6GIxBnjUw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-cli/-/inspector-cli-0.17.0.tgz", + "integrity": "sha512-T6loUwwjSV1m6THJ0o0zDlh+eLWYOAouMW/ZWrCv+iLwP7ye1KSL/CEOPilZz8a/lrVpmYfP59S/5hZxuKAqig==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.18.0", "commander": "^13.1.0", "spawn-rx": "^5.1.2" }, @@ -1710,48 +1712,14 @@ "mcp-inspector-cli": "build/cli.js" } }, - "node_modules/@modelcontextprotocol/inspector-cli/node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", - "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/inspector-cli/node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@modelcontextprotocol/inspector-client": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-client/-/inspector-client-0.16.6.tgz", - "integrity": "sha512-2dwB0OXI02PTTsECCTIsB9DkERImIrsTAuZW6LlfUojtQMLI5NpuUID4Y4LaYPcdGnxkkkR1eddrPTsuzgabvg==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-client/-/inspector-client-0.17.0.tgz", + "integrity": "sha512-L5NultdyPXB3KsuTDdm+gNNrb5RKP8keQJJiYDlNOBtwyY/Y7TUUm6QGr/fMX28/Uasnq0ktH5rijYzpfiWTsA==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.18.0", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.3", "@radix-ui/react-icons": "^1.3.0", @@ -1759,6 +1727,7 @@ "@radix-ui/react-popover": "^1.1.3", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.1", "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-tooltip": "^1.1.8", @@ -1780,50 +1749,18 @@ "mcp-inspector-client": "bin/start.js" } }, - "node_modules/@modelcontextprotocol/inspector-client/node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", - "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/inspector-client/node_modules/@modelcontextprotocol/sdk/node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@modelcontextprotocol/inspector-server": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-server/-/inspector-server-0.16.6.tgz", - "integrity": "sha512-BkE/4K2Y8ZcXK/cGBucG+rLTcTIUAaSyQabxqh0p+ErhkJDmepDvI+63OqQnauWUJydXPZYtBQyHppL4JN7RGw==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/inspector-server/-/inspector-server-0.17.0.tgz", + "integrity": "sha512-gT9Nad/p5iy638Q9uL7DqGTu7TwtmKf3vhZYliLEsRfvxfhNfz86skAnXAHzAFGZZqM2SuQMDrp/1hF/ceG7Jg==", "dev": true, "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.17.5", + "@modelcontextprotocol/sdk": "^1.18.0", "cors": "^2.8.5", "express": "^5.1.0", + "shell-quote": "^1.8.3", + "spawn-rx": "^5.1.2", "ws": "^8.18.0", "zod": "^3.25.76" }, @@ -1831,78 +1768,10 @@ "mcp-inspector-server": "build/index.js" } }, - "node_modules/@modelcontextprotocol/inspector-server/node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", - "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/inspector-server/node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/@modelcontextprotocol/inspector/node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.5.tgz", - "integrity": "sha512-QakrKIGniGuRVfWBdMsDea/dx1PNE739QJ7gCM41s9q+qaCYTHCdsIBXQVVXry3mfWAiaM9kT22Hyz53Uw8mfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.6", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.23.8", - "zod-to-json-schema": "^3.24.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@modelcontextprotocol/inspector/node_modules/pkce-challenge": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", - "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.0.tgz", - "integrity": "sha512-qFfbWFA7r1Sd8D697L7GkTd36yqDuTkvz0KfOGkgXR8EUhQn3/EDNIR/qUdQNMT8IjmasBvHWuXeisxtXTQT2g==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.20.0.tgz", + "integrity": "sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==", "license": "MIT", "dependencies": { "ajv": "^6.12.6", @@ -2550,6 +2419,36 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tabs": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", @@ -3058,17 +2957,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", - "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", + "integrity": "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/type-utils": "8.38.0", - "@typescript-eslint/utils": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/type-utils": "8.45.0", + "@typescript-eslint/utils": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -3082,9 +2981,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.38.0", + "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -3098,16 +2997,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", - "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.45.0.tgz", + "integrity": "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "engines": { @@ -3119,18 +3018,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", - "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.45.0.tgz", + "integrity": "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.38.0", - "@typescript-eslint/types": "^8.38.0", + "@typescript-eslint/tsconfig-utils": "^8.45.0", + "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "engines": { @@ -3141,18 +3040,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", - "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.45.0.tgz", + "integrity": "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0" + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3163,9 +3062,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", - "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.45.0.tgz", + "integrity": "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w==", "dev": true, "license": "MIT", "engines": { @@ -3176,19 +3075,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", - "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.45.0.tgz", + "integrity": "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -3201,13 +3100,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", - "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.45.0.tgz", + "integrity": "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA==", "dev": true, "license": "MIT", "engines": { @@ -3219,16 +3118,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", - "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.45.0.tgz", + "integrity": "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.38.0", - "@typescript-eslint/tsconfig-utils": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/visitor-keys": "8.38.0", + "@typescript-eslint/project-service": "8.45.0", + "@typescript-eslint/tsconfig-utils": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -3244,7 +3143,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { @@ -3287,16 +3186,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", - "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.45.0.tgz", + "integrity": "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.38.0", - "@typescript-eslint/types": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0" + "@typescript-eslint/scope-manager": "8.45.0", + "@typescript-eslint/types": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3307,17 +3206,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", - "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.45.0.tgz", + "integrity": "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3790,13 +3689,6 @@ "node": ">=10" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/azure-devops-extension-api": { "version": "4.258.0", "resolved": "https://registry.npmjs.org/azure-devops-extension-api/-/azure-devops-extension-api-4.258.0.tgz", @@ -3810,18 +3702,18 @@ } }, "node_modules/azure-devops-extension-sdk": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/azure-devops-extension-sdk/-/azure-devops-extension-sdk-4.0.2.tgz", - "integrity": "sha512-r8JwhjQT3el/X7XyMJGq/mlNDN/Cdpw5Dw/2nXcCdBmfyvDMgCcD0a+4EgL7Fi4ULcy+hPrlwejv8l1K4/XZSQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/azure-devops-extension-sdk/-/azure-devops-extension-sdk-4.2.0.tgz", + "integrity": "sha512-6/4Rgj0rvwgsymw1cDheGdlSUWnaa1IoJ/3vdQmqUCUZw6pSd+utxs4m5KBi3Eic8NiG7UjxoWgQQCFavgKehA==", "license": "MIT", "engines": { "node": ">=18.0.0" } }, "node_modules/azure-devops-node-api": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-15.1.0.tgz", - "integrity": "sha512-zlZ387CISkSKK1vjBv53kzR5fnzA60SxYrejypZawefZWvrjC28zyM/iKSP5b+iYl+Z7OOlm+Rgl6YsMecK6fg==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-15.1.1.tgz", + "integrity": "sha512-ohL2CY+zRAItKvwkHhefYxjr0Hndu6s8qKwyl0+wL4Ol6c4UrsI3A3G6ZPwwK81c1Ga3dEXjeDg4aKV4hn9loA==", "license": "MIT", "dependencies": { "tunnel": "0.0.6", @@ -4522,6 +4414,16 @@ "node": ">= 8" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -4718,22 +4620,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/electron-to-chromium": { "version": "1.5.162", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.162.tgz", @@ -5256,51 +5142,42 @@ "bser": "2.1.1" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "flat-cache": "^3.0.4" + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" }, "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" + "node": "^12.20 || >= 14.13" } }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" + "flat-cache": "^3.0.4" }, "engines": { - "node": ">=10" + "node": "^10.12.0 || >=12.0.0" } }, "node_modules/fill-range": { @@ -5405,6 +5282,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -5709,6 +5599,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6183,25 +6095,6 @@ "@pkgjs/parseargs": "^0.11.0" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" - }, - "bin": { - "jake": "bin/cli.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest": { "version": "30.0.5", "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.5.tgz", @@ -7891,6 +7784,13 @@ "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -7898,6 +7798,46 @@ "dev": true, "license": "MIT" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -9592,15 +9532,15 @@ } }, "node_modules/ts-jest": { - "version": "29.4.0", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", - "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", + "version": "29.4.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", + "integrity": "sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", @@ -9822,9 +9762,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -9836,16 +9776,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.38.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", - "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "version": "8.45.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.45.0.tgz", + "integrity": "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.38.0", - "@typescript-eslint/parser": "8.38.0", - "@typescript-eslint/typescript-estree": "8.38.0", - "@typescript-eslint/utils": "8.38.0" + "@typescript-eslint/eslint-plugin": "8.45.0", + "@typescript-eslint/parser": "8.45.0", + "@typescript-eslint/typescript-estree": "8.45.0", + "@typescript-eslint/utils": "8.45.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -9856,7 +9796,21 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" } }, "node_modules/underscore": { @@ -10051,6 +10005,16 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/whatwg-fetch": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.1.tgz", @@ -10083,6 +10047,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", diff --git a/package.json b/package.json index 9777d62..5d94401 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure-devops/mcp", - "version": "2.2.0", + "version": "2.2.1", "description": "MCP server for interacting with Azure DevOps", "license": "MIT", "author": "Microsoft Corporation", @@ -38,7 +38,7 @@ }, "dependencies": { "@azure/identity": "^4.10.0", - "@modelcontextprotocol/sdk": "1.17.0", + "@modelcontextprotocol/sdk": "1.20.0", "azure-devops-extension-api": "^4.252.0", "azure-devops-extension-sdk": "^4.0.2", "azure-devops-node-api": "^15.1.0", @@ -47,7 +47,7 @@ "zod-to-json-schema": "^3.24.5" }, "devDependencies": { - "@modelcontextprotocol/inspector": "^0.16.1", + "@modelcontextprotocol/inspector": "^0.17.0", "@types/jest": "^30.0.0", "@types/node": "^22", "eslint-config-prettier": "10.1.8", @@ -59,7 +59,7 @@ "shx": "^0.4.0", "ts-jest": "^29.4.0", "tsconfig-paths": "^4.2.0", - "typescript": "^5.8.3", - "typescript-eslint": "^8.32.1" + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0" } } diff --git a/src/auth.ts b/src/auth.ts index 1f8db4c..303297b 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -7,16 +7,23 @@ const scopes = ["499b84ac-1321-427f-aa17-267ca6975798/.default"]; class OAuthAuthenticator { static clientId = "0d50963b-7bb9-4fe7-94c7-a99af00b5136"; static defaultAuthority = "https://login.microsoftonline.com/common"; + static zeroTenantId = "00000000-0000-0000-0000-000000000000"; private accountId: AccountInfo | null; private publicClientApp: PublicClientApplication; constructor(tenantId?: string) { this.accountId = null; + + let authority = OAuthAuthenticator.defaultAuthority; + if (tenantId && tenantId !== OAuthAuthenticator.zeroTenantId) { + authority = `https://login.microsoftonline.com/${tenantId}`; + } + this.publicClientApp = new PublicClientApplication({ auth: { clientId: OAuthAuthenticator.clientId, - authority: tenantId ? `https://login.microsoftonline.com/${tenantId}` : OAuthAuthenticator.defaultAuthority, + authority, }, }); } diff --git a/src/index.ts b/src/index.ts index e01f88e..b48cffc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,7 +11,7 @@ import { hideBin } from "yargs/helpers"; import { createAuthenticator } from "./auth.js"; import { getOrgTenant } from "./org-tenants.js"; -import { configurePrompts } from "./prompts.js"; +//import { configurePrompts } from "./prompts.js"; import { configureAllTools } from "./tools.js"; import { UserAgentComposer } from "./useragent.js"; import { packageVersion } from "./version.js"; @@ -89,7 +89,8 @@ async function main() { const tenantId = (await getOrgTenant(orgName)) ?? argv.tenant; const authenticator = createAuthenticator(argv.authentication, tenantId); - configurePrompts(server); + // removing prompts untill further notice + // configurePrompts(server); configureAllTools(server, authenticator, getAzureDevOpsClient(authenticator, userAgentComposer), () => userAgentComposer.userAgent, enabledDomains); diff --git a/src/prompts.ts b/src/prompts.ts index bbd7960..75c9da0 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -2,9 +2,7 @@ // Licensed under the MIT License. import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { z } from "zod"; import { CORE_TOOLS } from "./tools/core.js"; -import { WORKITEM_TOOLS } from "./tools/work-items.js"; function configurePrompts(server: McpServer) { server.prompt("Projects", "Lists all projects in the Azure DevOps organization.", {}, () => ({ @@ -21,41 +19,6 @@ Present the results in alphabetical order in a table with the following columns: }, ], })); - - server.prompt("Teams", "Retrieves all teams for a given Azure DevOps project.", { project: z.string() }, ({ project }) => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: String.raw` - # Task - Use the '${CORE_TOOLS.list_project_teams}' tool to retrieve all teams for the project '${project}'. - Present the results in alphabetical order in a table with the following columns: Name and Id`, - }, - }, - ], - })); - - server.prompt( - "getWorkItem", - "Retrieves details for a specific Azure DevOps work item by ID.", - { id: z.string().describe("The ID of the work item to retrieve."), project: z.string().describe("The name or ID of the Azure DevOps project.") }, - ({ id, project }) => ({ - messages: [ - { - role: "user", - content: { - type: "text", - text: String.raw` - # Task - Use the '${WORKITEM_TOOLS.get_work_item}' tool to retrieve details for the work item with ID '${id}' in project '${project}'. - Present the following fields: ID, Title, State, Assigned To, Work Item Type, Description or Repro Steps, and Created Date.`, - }, - }, - ], - }) - ); } export { configurePrompts }; diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index 6e56215..9f10e23 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -15,6 +15,11 @@ import { GitPullRequestQueryType, CommentThreadContext, CommentThreadStatus, + GitPullRequestCompletionOptions, + GitPullRequestMergeStrategy, + GitPullRequest, + GitPullRequestCommentThread, + Comment, } from "azure-devops-node-api/interfaces/GitInterfaces.js"; import { z } from "zod"; import { getCurrentUserDetails, getUserIdFromEmail } from "./auth.js"; @@ -23,8 +28,7 @@ import { getEnumKeys } from "../utils.js"; const REPO_TOOLS = { list_repos_by_project: "repo_list_repos_by_project", - list_pull_requests_by_repo: "repo_list_pull_requests_by_repo", - list_pull_requests_by_project: "repo_list_pull_requests_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", @@ -52,12 +56,22 @@ function branchesFilterOutIrrelevantProperties(branches: GitRef[], top: number) .slice(0, top); } +function trimPullRequestThread(thread: GitPullRequestCommentThread) { + return { + id: thread.id, + publishedDate: thread.publishedDate, + lastUpdatedDate: thread.lastUpdatedDate, + status: thread.status, + comments: trimComments(thread.comments), + }; +} + /** * 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: any[] | undefined | null) { +function trimComments(comments: Comment[] | undefined | null) { return comments ?.filter((comment) => !comment.isDeleted) // Exclude deleted comments ?.map((comment) => ({ @@ -97,6 +111,25 @@ function filterReposByName(repositories: GitRepository[], repoNameFilter: string return filteredByName; } +function trimPullRequest(pr: GitPullRequest, includeDescription = false) { + return { + pullRequestId: pr.pullRequestId, + codeReviewId: pr.codeReviewId, + repository: pr.repository?.name, + status: pr.status, + createdBy: { + displayName: pr.createdBy?.displayName, + uniqueName: pr.createdBy?.uniqueName, + }, + creationDate: pr.creationDate, + title: pr.title, + ...(includeDescription ? { description: pr.description ?? "" } : {}), + isDraft: pr.isDraft, + sourceRefName: pr.sourceRefName, + targetRefName: pr.targetRefName, + }; +} + function configureRepoTools(server: McpServer, tokenProvider: () => Promise, connectionProvider: () => Promise, userAgentProvider: () => string) { server.tool( REPO_TOOLS.create_pull_request, @@ -137,8 +170,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Promise { + async ({ repositoryId, pullRequestId, title, description, isDraft, targetRefName, status, autoComplete, mergeStrategy, deleteSourceBranch, transitionWorkItems, bypassReason }) => { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); // Build update object with only provided fields - const updateRequest: { - title?: string; - description?: string; - isDraft?: boolean; - targetRefName?: string; - status?: number; - } = {}; + const updateRequest: Record = {}; + if (title !== undefined) updateRequest.title = title; if (description !== undefined) updateRequest.description = description; if (isDraft !== undefined) updateRequest.isDraft = isDraft; @@ -268,18 +306,46 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise ({ + 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(updatedPullRequest, null, 2) }], + content: [{ type: "text", text: JSON.stringify(trimmedResponse, null, 2) }], }; } else { for (const reviewerId of reviewerIds) { @@ -356,10 +431,11 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise { + async ({ repositoryId, project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => { const connection = await connectionProvider(); const gitApi = await connection.getGitApi(); // Build the search criteria const searchCriteria: { status: number; - repositoryId: string; + repositoryId?: string; creatorId?: string; reviewerId?: string; sourceRefName?: string; targetRefName?: string; } = { status: pullRequestStatusStringToInt(status), - repositoryId: repositoryId, }; + if (!repositoryId && !project) { + return { + content: [ + { + type: "text", + text: "Either repositoryId or project must be provided.", + }, + ], + isError: true, + }; + } + + if (repositoryId) { + searchCriteria.repositoryId = repositoryId; + } + if (sourceRefName) { searchCriteria.sourceRefName = sourceRefName; } @@ -443,147 +534,39 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise ({ - pullRequestId: pr.pullRequestId, - codeReviewId: pr.codeReviewId, - status: pr.status, - createdBy: { - displayName: pr.createdBy?.displayName, - uniqueName: pr.createdBy?.uniqueName, - }, - creationDate: pr.creationDate, - title: pr.title, - isDraft: pr.isDraft, - sourceRefName: pr.sourceRefName, - targetRefName: pr.targetRefName, - })); - - return { - content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }], - }; - } - ); - - server.tool( - REPO_TOOLS.list_pull_requests_by_project, - "Retrieve a list of pull requests for a given project Id or Name.", - { - project: z.string().describe("The name or ID of the Azure DevOps project."), - top: z.number().default(100).describe("The maximum number of pull requests to return."), - skip: z.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() - .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')."), - }, - async ({ project, top, skip, created_by_me, created_by_user, i_am_reviewer, user_is_reviewer, status, sourceRefName, targetRefName }) => { - const connection = await connectionProvider(); - const gitApi = await connection.getGitApi(); - - // Build the search criteria - const gitPullRequestSearchCriteria: { - status: number; - creatorId?: string; - reviewerId?: string; - sourceRefName?: string; - targetRefName?: string; - } = { - status: pullRequestStatusStringToInt(status), - }; - - if (sourceRefName) { - gitPullRequestSearchCriteria.sourceRefName = sourceRefName; - } - - if (targetRefName) { - gitPullRequestSearchCriteria.targetRefName = targetRefName; - } - - if (created_by_user) { - try { - const userId = await getUserIdFromEmail(created_by_user, tokenProvider, connectionProvider, userAgentProvider); - gitPullRequestSearchCriteria.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); - const userId = data.authenticatedUser.id; - gitPullRequestSearchCriteria.creatorId = userId; - } - - if (user_is_reviewer) { - try { - const reviewerUserId = await getUserIdFromEmail(user_is_reviewer, tokenProvider, connectionProvider, userAgentProvider); - gitPullRequestSearchCriteria.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); - const userId = data.authenticatedUser.id; - gitPullRequestSearchCriteria.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 + return { + content: [ + { + type: "text", + text: "Either repositoryId or project must be provided.", + }, + ], + isError: true, + }; } - const pullRequests = await gitApi.getPullRequestsByProject( - project, - gitPullRequestSearchCriteria, - undefined, // maxCommentLength - skip, - top - ); - - // Filter out the irrelevant properties - const filteredPullRequests = pullRequests?.map((pr) => ({ - pullRequestId: pr.pullRequestId, - codeReviewId: pr.codeReviewId, - repository: pr.repository?.name, - status: pr.status, - createdBy: { - displayName: pr.createdBy?.displayName, - uniqueName: pr.createdBy?.uniqueName, - }, - creationDate: pr.creationDate, - title: pr.title, - isDraft: pr.isDraft, - sourceRefName: pr.sourceRefName, - targetRefName: pr.targetRefName, - })); + const filteredPullRequests = pullRequests?.map((pr) => trimPullRequest(pr)); return { content: [{ type: "text", text: JSON.stringify(filteredPullRequests, null, 2) }], @@ -619,13 +602,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise ({ - id: thread.id, - publishedDate: thread.publishedDate, - lastUpdatedDate: thread.lastUpdatedDate, - status: thread.status, - comments: trimComments(thread.comments), - })); + const trimmedThreads = paginatedThreads?.map((thread) => trimPullRequestThread(thread)); return { content: [{ type: "text", text: JSON.stringify(trimmedThreads, null, 2) }], @@ -902,8 +879,10 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise, connectionProvider: () => Promise) { - /* - LIST OF TEST PLANS - get list of test plans by project - */ 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.", @@ -43,9 +40,6 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - Create Test Plan - CREATE - */ server.tool( Test_Plan_Tools.create_test_plan, "Creates a new test plan in the project.", @@ -79,9 +73,6 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - Create Test Suite - CREATE - */ server.tool( Test_Plan_Tools.create_test_suite, "Creates a new test suite in a test plan.", @@ -112,9 +103,6 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - Add Test Cases to Suite - ADD - */ server.tool( Test_Plan_Tools.add_test_cases_to_suite, "Adds existing test cases to a test suite.", @@ -139,9 +127,6 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - Create Test Case - CREATE - */ server.tool( Test_Plan_Tools.create_test_case, "Creates a new test case work item.", @@ -157,8 +142,9 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con priority: z.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.number().optional().describe("Optional work item id that will be set as a Microsoft.VSTS.Common.TestedBy-Reverse link to the test case."), }, - async ({ project, title, steps, priority, areaPath, iterationPath }) => { + async ({ project, title, steps, priority, areaPath, iterationPath, testsWorkItemId }) => { const connection = await connectionProvider(); const witClient = await connection.getWorkItemTrackingApi(); @@ -176,6 +162,17 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con 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", @@ -216,10 +213,45 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - TEST PLANS - Gets a list of test cases for a given testplan. - */ + server.tool( + Test_Plan_Tools.update_test_case_steps, + "Update an existing test case work item.", + { + id: z.number().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." + ), + }, + async ({ id, steps }) => { + 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 = []; + + if (stepsXml) { + 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) }], + }; + } + ); + server.tool( Test_Plan_Tools.list_test_cases, "Gets a list of test cases in the test plan.", @@ -239,9 +271,6 @@ function configureTestPlanTools(server: McpServer, _: () => Promise, con } ); - /* - Gets a list of test results for a given project and build ID - */ server.tool( Test_Plan_Tools.test_results_from_build_id, "Gets a list of test results for a given project and build ID.", diff --git a/src/version.ts b/src/version.ts index c2ffd5a..5d5a0e8 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const packageVersion = "2.2.0"; +export const packageVersion = "2.2.1"; diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts index 92b232a..7d127e8 100644 --- a/test/src/tools/repositories.test.ts +++ b/test/src/tools/repositories.test.ts @@ -90,9 +90,19 @@ describe("repos tools", () => { const mockUpdatedPR = { pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", title: "Updated Title", description: "Updated Description", isDraft: true, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", }; mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); @@ -118,7 +128,23 @@ describe("repos tools", () => { 123 ); - expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Updated Title", + description: "Updated Description", + isDraft: true, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should update pull request with only title", async () => { @@ -129,7 +155,21 @@ describe("repos tools", () => { if (!call) throw new Error("repo_update_pull_request tool not registered"); const [, , , handler] = call; - const mockUpdatedPR = { pullRequestId: 123, title: "New Title" }; + const mockUpdatedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "New Title", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { @@ -148,7 +188,23 @@ describe("repos tools", () => { 123 ); - expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "New Title", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should update pull request status to Active", async () => { @@ -159,7 +215,21 @@ describe("repos tools", () => { if (!call) throw new Error("repo_update_pull_request tool not registered"); const [, , , handler] = call; - const mockUpdatedPR = { pullRequestId: 123, status: PullRequestStatus.Active }; + const mockUpdatedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { @@ -178,7 +248,23 @@ describe("repos tools", () => { 123 ); - expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should update pull request status to Abandoned", async () => { @@ -189,7 +275,21 @@ describe("repos tools", () => { if (!call) throw new Error("repo_update_pull_request tool not registered"); const [, , , handler] = call; - const mockUpdatedPR = { pullRequestId: 123, status: PullRequestStatus.Abandoned }; + const mockUpdatedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: PullRequestStatus.Abandoned, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); const params = { @@ -208,7 +308,23 @@ describe("repos tools", () => { 123 ); - expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: PullRequestStatus.Abandoned, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should update pull request with status and other fields", async () => { @@ -221,8 +337,18 @@ describe("repos tools", () => { const mockUpdatedPR = { pullRequestId: 123, - title: "Updated Title", + codeReviewId: 123, + repository: { name: "test-repo" }, status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Updated Title", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", }; mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); @@ -244,7 +370,23 @@ describe("repos tools", () => { 123 ); - expect(result.content[0].text).toBe(JSON.stringify(mockUpdatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Updated Title", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should return error when no fields provided", async () => { @@ -264,7 +406,188 @@ describe("repos tools", () => { expect(mockGitApi.updatePullRequest).not.toHaveBeenCalled(); expect(result.isError).toBe(true); - expect(result.content[0].text).toContain("At least one field (title, description, isDraft, targetRefName, or status) must be provided for update."); + expect(result.content[0].text).toContain("At least one field (title, description, isDraft, targetRefName, status, or autoComplete options) must be provided for update."); + }); + + 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 [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + title: "Updated PR", + autoCompleteSetBy: { id: "user-id" }, + completionOptions: { + mergeStrategy: 2, // Squash + deleteSourceBranch: true, + transitionWorkItems: true, + bypassPolicy: false, + }, + }; + + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + mockGetCurrentUserDetails.mockResolvedValue({ + authenticatedUser: { id: "current-user-id" }, + authorizedUser: { id: "current-user-id" }, + }); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + autoComplete: true, + mergeStrategy: "Squash", + deleteSourceBranch: true, + transitionWorkItems: true, + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: { id: "current-user-id" }, + completionOptions: expect.objectContaining({ + mergeStrategy: 2, // GitPullRequestMergeStrategy.Squash + deleteSourceBranch: true, + transitionWorkItems: true, + bypassPolicy: false, + }), + }), + "test-repo-id", + 123 + ); + expect(result.isError).toBeFalsy(); + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.pullRequestId).toBe(123); + }); + + 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 [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + title: "Updated PR", + autoCompleteSetBy: null, + completionOptions: null, + }; + + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + autoComplete: false, + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: null, + completionOptions: null, + }), + "test-repo-id", + 123 + ); + expect(result.isError).toBeFalsy(); + }); + + 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 [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + autoComplete: true, + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: { id: "user123" }, + completionOptions: expect.objectContaining({ + bypassPolicy: false, + }), + }), + "test-repo-id", + 123 + ); + expect(result.isError).toBeFalsy(); + }); + + 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 [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + autoComplete: true, + bypassReason: "Emergency fix needed", + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: { id: "user123" }, + completionOptions: expect.objectContaining({ + bypassPolicy: true, + bypassReason: "Emergency fix needed", + }), + }), + "test-repo-id", + 123 + ); + expect(result.isError).toBeFalsy(); }); }); @@ -278,7 +601,16 @@ describe("repos tools", () => { const mockCreatedPR = { pullRequestId: 456, + codeReviewId: 456, + repository: { name: "test-repo" }, + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", title: "New Feature", + isDraft: false, sourceRefName: "refs/heads/feature-branch", targetRefName: "refs/heads/main", }; @@ -306,7 +638,23 @@ describe("repos tools", () => { "repo123" ); - expect(result.content[0].text).toBe(JSON.stringify(mockCreatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 456, + codeReviewId: 456, + repository: "test-repo", + status: 1, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "New Feature", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature-branch", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should create pull request with all optional fields", async () => { @@ -316,7 +664,22 @@ describe("repos tools", () => { if (!call) throw new Error("repo_create_pull_request tool not registered"); const [, , , handler] = call; - const mockCreatedPR = { pullRequestId: 456 }; + const mockCreatedPR = { + pullRequestId: 456, + codeReviewId: 456, + repository: { name: "test-repo" }, + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "New Feature", + description: "This is a new feature", + isDraft: true, + sourceRefName: "refs/heads/feature-branch", + targetRefName: "refs/heads/main", + }; mockGitApi.createPullRequest.mockResolvedValue(mockCreatedPR); const params = { @@ -349,7 +712,23 @@ describe("repos tools", () => { "repo123" ); - expect(result.content[0].text).toBe(JSON.stringify(mockCreatedPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 456, + codeReviewId: 456, + repository: "test-repo", + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "New Feature", + description: "This is a new feature", + isDraft: true, + sourceRefName: "refs/heads/feature-branch", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); }); @@ -787,16 +1166,16 @@ describe("repos tools", () => { const parsedResult = JSON.parse(result.content[0].text); expect(parsedResult).toHaveLength(2); - expect(parsedResult.map((r: any) => r.name).sort()).toEqual(["frontend-app", "frontend-web"]); + expect(parsedResult.map((r: { name: string }) => r.name).sort()).toEqual(["frontend-app", "frontend-web"]); }); }); - describe("repo_list_pull_requests_by_repo", () => { + describe("repo_list_pull_requests_by_repo_or_project - repository tests", () => { 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; const mockPRs = [ @@ -833,8 +1212,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -856,8 +1235,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -879,8 +1258,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -910,8 +1289,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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; // Mock successful user lookup @@ -935,8 +1314,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -968,8 +1347,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1001,8 +1380,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1036,8 +1415,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -1076,8 +1455,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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; // Mock successful user lookup @@ -1101,8 +1480,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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; // Mock successful user lookup @@ -1135,8 +1514,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; // Mock user lookup failure @@ -1159,12 +1538,12 @@ describe("repos tools", () => { }); }); - describe("repo_list_pull_requests_by_project", () => { + describe("repo_list_pull_requests_by_repo_or_project - project tests", () => { 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; const mockPRs = [ @@ -1215,8 +1594,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; const mockPRs = [ @@ -1269,8 +1648,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; const mockPRs = [ @@ -1323,8 +1702,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; const mockPRs = [ @@ -1378,8 +1757,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock getUserIdFromEmail to return a specific user ID @@ -1437,8 +1816,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1468,8 +1847,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1499,8 +1878,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1532,8 +1911,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -1568,8 +1947,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock successful user lookup @@ -1624,8 +2003,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock successful user lookup @@ -1651,8 +2030,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock user lookup failure @@ -1677,8 +2056,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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock both user lookups @@ -2555,8 +2934,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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGetCurrentUserDetails.mockResolvedValue({ @@ -2580,8 +2959,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -2601,8 +2980,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -2622,8 +3001,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -2643,8 +3022,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; const params = { @@ -2662,8 +3041,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; // Mock getUserIdFromEmail to throw an error @@ -2686,8 +3065,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; // Mock getUserIdFromEmail to throw an error @@ -2809,9 +3188,23 @@ describe("repos tools", () => { if (!call) throw new Error("repo_create_pull_request tool not registered"); const [, , , handler] = call; - const mockPR = { pullRequestId: 123, title: "Test PR" }; + const mockPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: { name: "test-repo" }, + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; mockGitApi.createPullRequest.mockResolvedValue(mockPR); - const params = { repositoryId: "repo123", sourceRefName: "refs/heads/feature", @@ -2837,7 +3230,23 @@ describe("repos tools", () => { "repo123" ); - expect(result.content[0].text).toBe(JSON.stringify(mockPR, null, 2)); + const expectedTrimmedPR = { + pullRequestId: 123, + codeReviewId: 123, + repository: "test-repo", + status: PullRequestStatus.Active, + createdBy: { + displayName: "Test User", + uniqueName: "testuser@example.com", + }, + creationDate: "2023-01-01T00:00:00Z", + title: "Test PR", + description: "", + isDraft: false, + sourceRefName: "refs/heads/feature", + targetRefName: "refs/heads/main", + }; + expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); it("should handle trimComments with undefined comments", async () => { @@ -3014,8 +3423,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequests.mockResolvedValue([]); @@ -3047,8 +3456,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGitApi.getPullRequestsByProject.mockResolvedValue([]); @@ -3346,8 +3755,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; const params = { @@ -3536,8 +3945,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3559,8 +3968,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue(new Error("User not found")); @@ -3691,8 +4100,8 @@ 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); - if (!call) throw new Error("repo_list_pull_requests_by_repo tool not registered"); + 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 [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception @@ -3714,8 +4123,8 @@ 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_project); - if (!call) throw new Error("repo_list_pull_requests_by_project tool not registered"); + 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 [, , , handler] = call; mockGetUserIdFromEmail.mockRejectedValue("String error"); // Non-Error exception diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index 48eac1c..9d5dd4a 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -1,4 +1,3 @@ -import { AccessToken } from "@azure/identity"; import { describe, expect, it } from "@jest/globals"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WebApi } from "azure-devops-node-api"; @@ -20,6 +19,7 @@ describe("configureTestPlanTools", () => { getTestResultsApi: () => Promise; getWorkItemTrackingApi: () => Promise; getTestApi: () => Promise; + serverUrl: string; }; let mockTestPlanApi: ITestPlanApi; let mockTestResultsApi: ITestResultsApi; @@ -41,6 +41,7 @@ describe("configureTestPlanTools", () => { } as unknown as ITestResultsApi; mockWitApi = { createWorkItem: jest.fn(), + updateWorkItem: jest.fn(), } as unknown as IWorkItemTrackingApi; mockTestApi = { addTestCasesToSuite: jest.fn(), @@ -50,6 +51,7 @@ describe("configureTestPlanTools", () => { getTestResultsApi: jest.fn().mockResolvedValue(mockTestResultsApi), getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWitApi), getTestApi: jest.fn().mockResolvedValue(mockTestApi), + serverUrl: "https://dev.azure.com/testorg", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); }); @@ -63,6 +65,8 @@ describe("configureTestPlanTools", () => { "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", ]) @@ -1106,6 +1110,536 @@ describe("configureTestPlanTools", () => { ); expect(result.content[0].text).toContain("Non-numbered Pipe Test"); }); + + 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 [, , , handler] = call; + + (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ + id: 2001, + fields: { + "System.Title": "Test Case with Link", + }, + relations: [ + { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: "https://dev.azure.com/testorg/proj1/_apis/wit/workItems/115304", + }, + ], + }); + + const params = { + project: "proj1", + title: "Test Case with Link", + steps: "1. Execute test|Test passes", + testsWorkItemId: 115304, + }; + const result = await handler(params); + + expect(mockWitApi.createWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/System.Title", + value: "Test Case with Link", + }), + expect.objectContaining({ + op: "add", + path: "/relations/-", + value: { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: "https://dev.azure.com/testorg/proj1/_apis/wit/workItems/115304", + }, + }), + ]), + "proj1", + "Test Case" + ); + expect(result.content[0].text).toBe( + JSON.stringify( + { + id: 2001, + fields: { + "System.Title": "Test Case with Link", + }, + relations: [ + { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: "https://dev.azure.com/testorg/proj1/_apis/wit/workItems/115304", + }, + ], + }, + null, + 2 + ) + ); + }); + + 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 [, , , handler] = call; + + (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ + id: 2002, + fields: { + "System.Title": "Test Case without Link", + }, + }); + + const params = { + project: "proj1", + title: "Test Case without Link", + steps: "1. Execute test|Test passes", + // testsWorkItemId not provided + }; + const result = await handler(params); + + const patchDocument = (mockWitApi.createWorkItem as jest.Mock).mock.calls[0][1]; + const relationsPatch = patchDocument.find((patch: { path: string }) => patch.path === "/relations/-"); + + expect(relationsPatch).toBeUndefined(); + expect(mockWitApi.createWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/System.Title", + value: "Test Case without Link", + }), + ]), + "proj1", + "Test Case" + ); + expect(result.content[0].text).toBe( + JSON.stringify( + { + id: 2002, + fields: { + "System.Title": "Test Case without Link", + }, + }, + null, + 2 + ) + ); + }); + + 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 [, , , handler] = call; + + (mockWitApi.createWorkItem as jest.Mock).mockResolvedValue({ + id: 2003, + fields: { + "System.Title": "Complete Test Case with Link", + "Microsoft.VSTS.Common.Priority": 1, + "System.AreaPath": "MyProject\\Feature", + "System.IterationPath": "MyProject\\Sprint 1", + }, + relations: [ + { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: "https://dev.azure.com/testorg/proj1/_apis/wit/workItems/115304", + }, + ], + }); + + const params = { + project: "proj1", + title: "Complete Test Case with Link", + steps: "1. Execute comprehensive test|All tests pass successfully", + priority: 1, + areaPath: "MyProject\\Feature", + iterationPath: "MyProject\\Sprint 1", + testsWorkItemId: 115304, + }; + const result = await handler(params); + + expect(mockWitApi.createWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/System.Title", + value: "Complete Test Case with Link", + }), + expect.objectContaining({ + op: "add", + path: "/relations/-", + value: { + rel: "Microsoft.VSTS.Common.TestedBy-Reverse", + url: "https://dev.azure.com/testorg/proj1/_apis/wit/workItems/115304", + }, + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.Common.Priority", + value: 1, + }), + expect.objectContaining({ + path: "/fields/System.AreaPath", + value: "MyProject\\Feature", + }), + expect.objectContaining({ + path: "/fields/System.IterationPath", + value: "MyProject\\Sprint 1", + }), + ]), + "proj1", + "Test Case" + ); + expect(result.content[0].text).toContain("Complete Test Case with Link"); + }); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136717, + rev: 2, + fields: { + "System.Title": "Updated Test Case", + "System.WorkItemType": "Test Case", + }, + }); + + const params = { + id: 136717, + steps: "1. Updated step 1|Expected result 1\n2. Updated step 2|Expected result 2", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith({}, expect.any(Array), 136717); + expect(result.content[0].text).toBe( + JSON.stringify( + { + id: 136717, + rev: 2, + fields: { + "System.Title": "Updated Test Case", + "System.WorkItemType": "Test Case", + }, + }, + null, + 2 + ) + ); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136718, + rev: 3, + fields: { + "System.Title": "Test Case with Pipe Delimiters", + }, + }); + + const params = { + 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", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Login to application"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("User is logged in successfully"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Navigate to dashboard"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Dashboard page loads correctly"), + }), + ]), + 136718 + ); + expect(result.content[0].text).toContain("Test Case with Pipe Delimiters"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136719, + rev: 2, + fields: { + "System.Title": "Test Case without Delimiters", + }, + }); + + const params = { + id: 136719, + steps: "1. Click button\n2. Verify result\n3. Close application", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Click button"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Verify step completes successfully"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Verify result"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Close application"), + }), + ]), + 136719 + ); + expect(result.content[0].text).toContain("Test Case without Delimiters"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136720, + rev: 2, + fields: { + "System.Title": "Test Case with XML Characters", + }, + }); + + const params = { + id: 136720, + steps: "1. Enter text with & 'quotes' and \"double quotes\"|Text is accepted correctly\n2. Submit form|Form submits without errors", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("<special> & 'quotes' and "double quotes""), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Text is accepted correctly"), + }), + ]), + 136720 + ); + expect(result.content[0].text).toContain("Test Case with XML Characters"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136721, + rev: 2, + fields: { + "System.Title": "Test Case with Empty Steps", + }, + }); + + const params = { + id: 136721, + steps: "1. Valid step\n\n \n2. Another valid step", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Valid step"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Another valid step"), + }), + ]), + 136721 + ); + expect(result.content[0].text).toContain("Test Case with Empty Steps"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockRejectedValue(new Error("API Error")); + + const params = { + id: 136722, + steps: "1. Test step that will fail", + }; + + await expect(handler(params)).rejects.toThrow("API Error"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136723, + rev: 2, + fields: { + "System.Title": "Mixed Steps Test Case", + }, + }); + + const params = { + id: 136723, + steps: "1. Numbered step one|Expected result one\nNon-numbered step\n3. Another numbered step|Expected result three", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Numbered step one"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Expected result one"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Non-numbered step"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Another numbered step"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Expected result three"), + }), + ]), + 136723 + ); + expect(result.content[0].text).toContain("Mixed Steps Test Case"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136724, + rev: 2, + fields: { + "System.Title": "Multiple Pipes Test Case", + }, + }); + + const params = { + id: 136724, + steps: "1. Check status message|Message shows 'Success | Warning | Error' status options", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Check status message"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Message shows 'Success"), + }), + ]), + 136724 + ); + expect(result.content[0].text).toContain("Multiple Pipes Test Case"); + }); + + 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 [, , , handler] = call; + + (mockWitApi.updateWorkItem as jest.Mock).mockResolvedValue({ + id: 136725, + rev: 2, + fields: { + "System.Title": "Empty Expected Results Test Case", + }, + }); + + const params = { + id: 136725, + steps: "1. Perform action|\n2. Another action|", + }; + const result = await handler(params); + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expect.arrayContaining([ + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Perform action"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Verify step completes successfully"), + }), + expect.objectContaining({ + path: "/fields/Microsoft.VSTS.TCM.Steps", + value: expect.stringContaining("Another action"), + }), + ]), + 136725 + ); + expect(result.content[0].text).toContain("Empty Expected Results Test Case"); + }); }); describe("add_test_cases_to_suite tool", () => {