diff --git a/.github/workflows/ai-issue-processing.yml b/.github/workflows/ai-issue-processing.yml deleted file mode 100644 index 7965892c..00000000 --- a/.github/workflows/ai-issue-processing.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: AI Issue Processing - -on: - workflow_dispatch: - -jobs: - issue-processing: - if: github.event.label.name == 'ai-issue-processing' - runs-on: ubuntu-latest - permissions: - contents: read - models: read - issues: write - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Add Needs Review Label - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit $ISSUE_NUMBER --add-label "Needs Review 👓" --repo ${{ github.repository }} - - - name: Call GitHub Model API - id: ai-inference - uses: actions/ai-inference@v1 - with: - model: gpt-4o-mini - system-prompt-file: .github/ai-automation/ai-issue-processing-system-prompt.md - prompt: | - Issue Title: ${{ github.event.issue.title }} - - Issue Description: - ${{ github.event.issue.body }} - - - name: Parse and Apply Labels - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - AI_RESPONSE: ${{ steps.ai-inference.outputs.response }} - run: | - echo "AI Response: $AI_RESPONSE" - - # Parse the JSON response to extract labels - LABELS=$(echo "$AI_RESPONSE" | python3 -c "import sys, json; print(' '.join([f'--add-label \"{label}\"' for label in json.load(sys.stdin)['labels']]))") - - # Add the recommended labels and remove the ai-issue-processing label - if [ -n "$LABELS" ]; then - eval gh issue edit $ISSUE_NUMBER $LABELS --remove-label "ai-issue-processing" --repo ${{ github.repository }} - else - gh issue edit $ISSUE_NUMBER --remove-label "ai-issue-processing" --repo ${{ github.repository }} - fi diff --git a/docs/TOOLSET.md b/docs/TOOLSET.md index eb7ddfc4..ff21f4ce 100644 --- a/docs/TOOLSET.md +++ b/docs/TOOLSET.md @@ -229,7 +229,7 @@ Lists artifacts for a given build. Downloads a pipeline artifact. - **Required**: `project`, `buildId`, `artifactName` -- **Optional**: `destinationPath` +- **Optional**: `destinationPath` (relative local path; absolute paths and path traversal are not allowed) ## Repositories diff --git a/package-lock.json b/package-lock.json index cbc62b2e..f9af048c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,7 @@ "husky": "^9.1.7", "jest": "^30.0.2", "jest-extended": "^7.0.0", - "lint-staged": "^16.2.7", + "lint-staged": "^17.0.0", "prettier": "3.8.3", "shx": "^0.4.0", "ts-jest": "^29.4.6", @@ -187,23 +187,22 @@ } }, "node_modules/@azure/msal-node": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.4.tgz", - "integrity": "sha512-G4LXGGggok1QC48uKu64/SV2DPRDlddmV8EieK8pflsNYMj9/Zz+Y9OHoEBhT15h+zpdwXXLYA/7PJCR/yZ8aw==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.1.5.tgz", + "integrity": "sha512-ObTeMoNPmq19X3z40et9Xvs4ZoWVeJg43PZMRLG5iwVL+2nCtAerG3YTDItqPp1CfXNwmCXBbg8jn1DOx65c3g==", "license": "MIT", "dependencies": { - "@azure/msal-common": "16.5.1", - "jsonwebtoken": "^9.0.0", - "uuid": "^8.3.0" + "@azure/msal-common": "16.5.2", + "jsonwebtoken": "^9.0.0" }, "engines": { "node": ">=20" } }, "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.5.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.1.tgz", - "integrity": "sha512-WS9w9SfI8SEYO7mTnxGeZ3UwQfhAVYCWglYF2/7GNx3ioHiAs2gPkl9eSwVs8cPrmiGh+zi9ai/OOKoq4cyzDw==", + "version": "16.5.2", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.5.2.tgz", + "integrity": "sha512-GkDEL6TYo3HgT3UuqakdgE9PZfc1hMki6+Hwgy1uddb/EauvAKfu85vVhuofRSo22D1xTnWt8Ucwfg4vSCVwvA==", "license": "MIT", "engines": { "node": ">=0.8.0" @@ -1909,17 +1908,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", - "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/type-utils": "8.59.0", - "@typescript-eslint/utils": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1932,7 +1931,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.59.0", + "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -1948,16 +1947,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", - "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1973,14 +1972,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", - "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.59.0", - "@typescript-eslint/types": "^8.59.0", + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "engines": { @@ -1995,14 +1994,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", - "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0" + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2013,9 +2012,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", - "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", "dev": true, "license": "MIT", "engines": { @@ -2030,15 +2029,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", - "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2055,9 +2054,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", - "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", "dev": true, "license": "MIT", "engines": { @@ -2069,16 +2068,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", - "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.59.0", - "@typescript-eslint/tsconfig-utils": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/visitor-keys": "8.59.0", + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2149,16 +2148,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", - "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.59.0", - "@typescript-eslint/types": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0" + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2173,13 +2172,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", - "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3111,14 +3110,14 @@ } }, "node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -3128,14 +3127,14 @@ } }, "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { "node": ">=20" @@ -3303,23 +3302,6 @@ "node": ">=12.20" } }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3902,9 +3884,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "dev": true, "license": "MIT" }, @@ -4032,12 +4014,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", - "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz", + "integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==", "license": "MIT", "dependencies": { - "ip-address": "10.1.0" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -4101,9 +4083,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -4319,9 +4301,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", - "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -4551,9 +4533,9 @@ } }, "node_modules/hono": { - "version": "4.12.14", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz", - "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==", + "version": "4.12.18", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", + "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -4742,9 +4724,9 @@ } }, "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "engines": { "node": ">= 12" @@ -6121,27 +6103,28 @@ "license": "MIT" }, "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.0.tgz", + "integrity": "sha512-286BsrsEp/7taRKY839AAU8E78uModiwmU7/cbuHbGfNA4mQVtLXA2Au9pLgWI2v4/M+PHq+s/rH+LJlVKucwA==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", + "listr2": "^10.2.1", + "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" + "tinyexec": "^1.1.2" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=20.17" + "node": ">=22.22.1" }, "funding": { "url": "https://opencollective.com/lint-staged" + }, + "optionalDependencies": { + "yaml": "^2.8.4" } }, "node_modules/lint-staged/node_modules/picomatch": { @@ -6158,21 +6141,20 @@ } }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, "node_modules/listr2/node_modules/ansi-styles": { @@ -6188,44 +6170,36 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -6326,9 +6300,9 @@ } }, "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -6361,6 +6335,23 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/log-update/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -7838,17 +7829,17 @@ } }, "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" @@ -8237,9 +8228,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -8546,16 +8537,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.59.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", - "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.59.0", - "@typescript-eslint/parser": "8.59.0", - "@typescript-eslint/typescript-estree": "8.59.0", - "@typescript-eslint/utils": "8.59.0" + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -8688,15 +8679,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -8963,11 +8945,12 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", "dev": true, "license": "ISC", + "optional": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 66cc3377..a8e9404a 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "husky": "^9.1.7", "jest": "^30.0.2", "jest-extended": "^7.0.0", - "lint-staged": "^16.2.7", + "lint-staged": "^17.0.0", "prettier": "3.8.3", "shx": "^0.4.0", "ts-jest": "^29.4.6", diff --git a/src/shared/elicitations.ts b/src/shared/elicitations.ts index 558dd487..0a009b4c 100644 --- a/src/shared/elicitations.ts +++ b/src/shared/elicitations.ts @@ -15,6 +15,13 @@ interface ElicitResponse { export type ElicitResult = ElicitResolved | ElicitResponse; export async function elicitProject(server: McpServer, connection: WebApi, message?: string): Promise { + // Check for default project from environment variable + const defaultProject = process.env.ado_mcp_project; + + if (defaultProject) { + return { resolved: defaultProject }; + } + const coreApi = await connection.getCoreApi(); const projects = await coreApi.getProjects("wellFormed", 100, 0, undefined, false); @@ -50,6 +57,13 @@ export async function elicitProject(server: McpServer, connection: WebApi, messa } export async function elicitTeam(server: McpServer, connection: WebApi, project: string, message?: string): Promise { + // Check for default team from environment variable + const defaultTeam = process.env.ado_mcp_team; + + if (defaultTeam) { + return { resolved: defaultTeam }; + } + const coreApi = await connection.getCoreApi(); const teams = await coreApi.getTeams(project, undefined, undefined, undefined, false); diff --git a/src/tools/pipelines.ts b/src/tools/pipelines.ts index 3000db59..c11679d9 100644 --- a/src/tools/pipelines.ts +++ b/src/tools/pipelines.ts @@ -35,7 +35,12 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< "Retrieves a list of build definitions for a given project.", { project: z.string().describe("Project ID or name to get build definitions for"), - repositoryId: z.string().optional().describe("Repository ID to filter build definitions"), + repositoryId: z + .string() + .optional() + .describe( + "Repository ID to filter build definitions. Can be a GUID or a repository name; when a name is provided, it is auto-resolved to the repository GUID using the project parameter (Azure Repos / TfsGit only)." + ), repositoryType: z.enum(["TfsGit", "GitHub", "BitbucketCloud"]).optional().describe("Type of repository to filter build definitions"), name: z.string().optional().describe("Name of the build definition to filter"), path: z.string().optional().describe("Path of the build definition to filter"), @@ -76,10 +81,29 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< }) => { const connection = await connectionProvider(); const buildApi = await connection.getBuildApi(); + + // Auto-resolve repositoryId from name to GUID for Azure Repos + let resolvedRepositoryId = repositoryId; + if (repositoryId) { + const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(repositoryId); + if (!isGuid && (!repositoryType || repositoryType === "TfsGit")) { + const gitApi = await connection.getGitApi(); + const repositories = await gitApi.getRepositories(project); + const repo = repositories?.find((r) => r.name === repositoryId); + if (!repo?.id) { + return { + content: [{ type: "text", text: `Error: Repository '${repositoryId}' not found in project '${project}'.` }], + isError: true, + }; + } + resolvedRepositoryId = repo.id; + } + } + const buildDefinitions = await buildApi.getDefinitions( project, name, - repositoryId, + resolvedRepositoryId, repositoryType, safeEnumConvert(DefinitionQueryOrder, queryOrder), top, @@ -532,23 +556,25 @@ function configurePipelineTools(server: McpServer, tokenProvider: () => Promise< server.tool( PIPELINE_TOOLS.pipelines_download_artifact, - "Downloads a pipeline artifact.", + "Downloads a pipeline artifact. When destinationPath is provided, it must be a relative local path; absolute paths and path traversal are not allowed.", { project: z.string().describe("The name or ID of the project."), buildId: z.coerce.number().min(1).describe("The ID of the build."), artifactName: z.string().describe("The name of the artifact to download."), - destinationPath: z.string().optional().describe("The local path to download the artifact to. If not provided, returns binary content as base64."), + destinationPath: z.string().optional().describe("The relative local path to download the artifact to. If not provided, returns binary content as base64."), }, async ({ project, buildId, artifactName, destinationPath }) => { - const isAbsolutePath = (value: string) => posix.isAbsolute(value) || win32.isAbsolute(value); + const hasUnsafePathSegment = (value: string) => value.split(/[\\/]+/).some((segment) => segment === "." || segment === ".."); + const hasPathSeparators = (value: string) => /[\\/]/.test(value); const hasDriveLetter = (value: string) => /^[a-zA-Z]:/.test(value); + const isAbsolutePath = (value: string) => posix.isAbsolute(value) || win32.isAbsolute(value); - if (artifactName.includes("..")) { - throw new Error("Invalid artifactName: path traversal is not allowed."); + if (hasUnsafePathSegment(artifactName) || hasPathSeparators(artifactName) || hasDriveLetter(artifactName) || isAbsolutePath(artifactName)) { + throw new Error("Invalid artifactName: artifactName must be a file name, not a path."); } - if (destinationPath && (destinationPath.includes("..") || isAbsolutePath(destinationPath) || hasDriveLetter(destinationPath))) { - throw new Error("Invalid destinationPath: absolute paths and path traversals are not allowed."); + if (destinationPath && (hasUnsafePathSegment(destinationPath) || isAbsolutePath(destinationPath) || hasDriveLetter(destinationPath))) { + throw new Error("Invalid destinationPath: use a relative path without path traversal."); } const connection = await connectionProvider(); diff --git a/src/tools/repositories.ts b/src/tools/repositories.ts index f0a53e4c..05501ed9 100644 --- a/src/tools/repositories.ts +++ b/src/tools/repositories.ts @@ -184,6 +184,9 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise ({ id: id.trim() })) : []; + const noDataErrorMessage = + `Pull request creation returned no data and no matching PR was found. This often means repositoryId=\"${repositoryId}\" was not resolvable. ` + + "Try the repository GUID from repo_list_repos_by_project instead of the Project/RepoName slash format."; const forkSource: GitForkRef | undefined = forkSourceRepositoryId ? { @@ -217,7 +220,8 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Promise Promise Promise Promise Promise Promise Promise 0) { - try { + try { + // Fetch diffs for modified files. Add/Delete files are excluded from getFileDiffs + // because they don't have two versions to compare; their content is fetched + // separately below via getItemText when includeLineContent is true. + let fileDiffs: any[] = []; + if (fileDiffParams.length > 0) { // Azure DevOps getFileDiffs API accepts max 10 files per request const FILE_DIFF_BATCH_SIZE = 10; - let fileDiffs: any[] = []; for (let i = 0; i < fileDiffParams.length; i += FILE_DIFF_BATCH_SIZE) { const batch = fileDiffParams.slice(i, i + FILE_DIFF_BATCH_SIZE); const batchDiffs = await gitApi.getFileDiffs( @@ -1213,226 +1227,234 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise { - // Normalize path for comparison (remove leading slash) - const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; - const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath); - return { - ...entry, - diff: matchingDiff || null, - }; - }), - }; - - // If includeLineContent is true, fetch actual file content with concurrency limit - if (includeLineContent && enrichedChanges.changeEntries) { - const CONCURRENCY_LIMIT = 10; - const entriesWithContent = [...enrichedChanges.changeEntries]; - for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) { - const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT); - const batchResults = await Promise.all( - batch.map(async (entry) => { - const ct = entry.changeType ?? 0; - const isAdd = !!(ct & VersionControlChangeType.Add); - const isDelete = !!(ct & VersionControlChangeType.Delete); - - const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; - - if (!entryPath) { - return entry; - } + // Merge diff content with change metadata. + // Added/deleted entries get diff: null here and are enriched below. + const enrichedChanges = { + ...changes, + changeEntries: changes.changeEntries.map((entry) => { + // Normalize path for comparison (remove leading slash) + const entryPath = entry.item?.path?.startsWith("/") ? entry.item.path.substring(1) : entry.item?.path; + const matchingDiff = fileDiffs.find((diff) => diff.path === entryPath); + return { + ...entry, + diff: matchingDiff || null, + }; + }), + }; - // Handle added files: fetch full content at target commit and create synthetic diff - if (isAdd && !entry.diff) { - try { - const targetStream = await gitApi - .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (targetStream) { - const targetText = await streamToString(targetStream); - const targetLines = targetText.split(/\r?\n/); - return { - ...entry, - diff: { - path: entryPath, - originalPath: entryPath, - lineDiffBlocks: [ - { - changeType: 1, // Add - originalLineNumberStart: 0, - originalLinesCount: 0, - modifiedLineNumberStart: 1, - modifiedLinesCount: targetLines.length, - modifiedLines: targetLines, - }, - ], - }, - }; - } - } catch (addError) { + // If includeLineContent is true, fetch actual file content with concurrency limit + if (includeLineContent && enrichedChanges.changeEntries) { + const CONCURRENCY_LIMIT = 10; + const entriesWithContent = [...enrichedChanges.changeEntries]; + for (let i = 0; i < entriesWithContent.length; i += CONCURRENCY_LIMIT) { + const batch = entriesWithContent.slice(i, i + CONCURRENCY_LIMIT); + const batchResults = await Promise.all( + batch.map(async (entry) => { + const ct = entry.changeType ?? 0; + const isAdd = !!(ct & VersionControlChangeType.Add); + const isDelete = !!(ct & VersionControlChangeType.Delete); + + const entryPath = entry.item?.path ? (entry.item.path.startsWith("/") ? entry.item.path.substring(1) : entry.item.path) : undefined; + // For deleted files ADO sets item.path to null and puts the path in originalPath only. + // Normalise originalPath once and use it as the fallback throughout. + const normalizedOriginalPath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : undefined; + // effectivePath is what we use as the "current" path for API calls / early-exit guard. + // For additions/modifications it's item.path; for deletions it's originalPath. + const effectivePath = entryPath ?? normalizedOriginalPath; + + if (!effectivePath) { + return entry; + } + + // Handle added files: fetch full content at target commit and create synthetic diff + if (isAdd && !entry.diff) { + try { + const targetStream = await gitApi + .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) + .catch(() => null); + if (targetStream) { + const targetText = await streamToString(targetStream); + const targetLines = targetText.split(/\r?\n/); return { ...entry, - _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`, + diff: { + path: effectivePath, + originalPath: null, + lineDiffBlocks: [ + { + changeType: 1, // Add + originalLineNumberStart: 0, + originalLinesCount: 0, + modifiedLineNumberStart: 1, + modifiedLinesCount: targetLines.length, + modifiedLines: targetLines, + }, + ], + }, }; } - return entry; + } catch (addError) { + return { + ...entry, + _contentFetchError: `Failed to fetch added file content: ${addError instanceof Error ? addError.message : "Unknown error"}`, + }; } + return entry; + } - // Handle deleted files: fetch full content at base commit and create synthetic diff - if (isDelete && !entry.diff) { - try { - const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath; - const baseStream = await gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null); - if (baseStream) { - const baseText = await streamToString(baseStream); - const baseLines = baseText.split(/\r?\n/); - return { - ...entry, - diff: { - path: entryPath, - originalPath: basePath, - lineDiffBlocks: [ - { - changeType: 2, // Delete - originalLineNumberStart: 1, - originalLinesCount: baseLines.length, - modifiedLineNumberStart: 0, - modifiedLinesCount: 0, - originalLines: baseLines, - }, - ], - }, - }; - } - } catch (delError) { + // Handle deleted files: fetch full content at base commit and create synthetic diff. + // basePath prefers originalPath (the pre-deletion path); falls back to effectivePath. + if (isDelete && !entry.diff) { + try { + const basePath = normalizedOriginalPath ?? effectivePath; + const baseStream = await gitApi + .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) + .catch(() => null); + if (baseStream) { + const baseText = await streamToString(baseStream); + const baseLines = baseText.split(/\r?\n/); return { ...entry, - _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`, + diff: { + path: null, + originalPath: basePath, + lineDiffBlocks: [ + { + changeType: 2, // Delete + originalLineNumberStart: 1, + originalLinesCount: baseLines.length, + modifiedLineNumberStart: 0, + modifiedLinesCount: 0, + originalLines: baseLines, + }, + ], + }, }; } - return entry; - } - - // For modified/renamed files, skip if no diff blocks - if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) { - return entry; + } catch (delError) { + return { + ...entry, + _contentFetchError: `Failed to fetch deleted file content: ${delError instanceof Error ? delError.message : "Unknown error"}`, + }; } - - // For renamed/moved files, the base version is at the original path - const basePath = entry.originalPath ? (entry.originalPath.startsWith("/") ? entry.originalPath.substring(1) : entry.originalPath) : entryPath; - - try { - // Fetch file content at both commits - const [baseContent, targetContent] = await Promise.all([ - // Base version (original) - use basePath for renamed files - gitApi - .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - // Target version (modified) - gitApi - .getItemText(repositoryId, entryPath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) - .catch(() => null), - ]); - - // Convert streams to text - const baseText = baseContent ? await streamToString(baseContent) : ""; - const targetText = targetContent ? await streamToString(targetContent) : ""; - - // Check if response is an Azure DevOps error (returned as JSON in the stream) - const checkForApiError = (text: string, label: string) => { - if (text.startsWith("{")) { - try { - const parsed = JSON.parse(text); - if (parsed.$id && parsed.innerException !== undefined) { - throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`); - } - } catch (e) { - if (e instanceof Error && e.message.startsWith("Failed to fetch")) throw e; - // Not valid JSON or not an error response — treat as legitimate content + return entry; + } + + // For modified/renamed files, skip if no diff blocks + if (!entry.diff?.lineDiffBlocks || entry.diff.lineDiffBlocks.length === 0) { + return entry; + } + + // For renamed/moved files, the base version is at the original path + const basePath = normalizedOriginalPath ?? effectivePath; + + try { + // Fetch file content at both commits + const [baseContent, targetContent] = await Promise.all([ + // Base version (original) - use basePath for renamed files + gitApi + .getItemText(repositoryId, basePath, project, undefined, undefined, undefined, undefined, undefined, { version: baseCommitId, versionType: GitVersionType.Commit }) + .catch(() => null), + // Target version (modified) + gitApi + .getItemText(repositoryId, effectivePath, project, undefined, undefined, undefined, undefined, undefined, { version: targetCommitId, versionType: GitVersionType.Commit }) + .catch(() => null), + ]); + + // Convert streams to text + const baseText = baseContent ? await streamToString(baseContent) : ""; + const targetText = targetContent ? await streamToString(targetContent) : ""; + + // Check if response is an Azure DevOps error (returned as JSON in the stream) + const checkForApiError = (text: string, label: string) => { + if (text.startsWith("{")) { + try { + const parsed = JSON.parse(text); + if (parsed.$id && parsed.innerException !== undefined) { + throw new Error(`Failed to fetch ${label} file content: ${parsed.message || text}`); } + } catch (e) { + if (e instanceof Error && e.message.startsWith("Failed to fetch")) throw e; + // Not valid JSON or not an error response — treat as legitimate content + } + } + }; + checkForApiError(baseText, "base"); + checkForApiError(targetText, "target"); + + // Split into lines + const baseLines = baseText.split(/\r?\n/); + const targetLines = targetText.split(/\r?\n/); + + // Enrich each lineDiffBlock with actual line content + const enrichedDiff = { + ...entry.diff, + lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block: any) => { + const enrichedBlock: any = { ...block }; + + // Add original (base) lines if they exist + if (block.originalLineNumberStart && block.originalLinesCount) { + const startIdx = block.originalLineNumberStart - 1; + const endIdx = startIdx + block.originalLinesCount; + enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx); } - }; - checkForApiError(baseText, "base"); - checkForApiError(targetText, "target"); - - // Split into lines - const baseLines = baseText.split(/\r?\n/); - const targetLines = targetText.split(/\r?\n/); - - // Enrich each lineDiffBlock with actual line content - const enrichedDiff = { - ...entry.diff, - lineDiffBlocks: entry.diff.lineDiffBlocks?.map((block: any) => { - const enrichedBlock: any = { ...block }; - - // Add original (base) lines if they exist - if (block.originalLineNumberStart && block.originalLinesCount) { - const startIdx = block.originalLineNumberStart - 1; - const endIdx = startIdx + block.originalLinesCount; - enrichedBlock.originalLines = baseLines.slice(startIdx, endIdx); - } - - // Add modified (target) lines if they exist - if (block.modifiedLineNumberStart && block.modifiedLinesCount) { - const startIdx = block.modifiedLineNumberStart - 1; - const endIdx = startIdx + block.modifiedLinesCount; - enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx); - } - return enrichedBlock; - }), - }; + // Add modified (target) lines if they exist + if (block.modifiedLineNumberStart && block.modifiedLinesCount) { + const startIdx = block.modifiedLineNumberStart - 1; + const endIdx = startIdx + block.modifiedLinesCount; + enrichedBlock.modifiedLines = targetLines.slice(startIdx, endIdx); + } - return { - ...entry, - diff: enrichedDiff, - }; - } catch (contentError) { - // If content fetch fails, return entry with error - return { - ...entry, - _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`, - }; - } - }) - ); - // Write batch results back into the array - for (let j = 0; j < batchResults.length; j++) { - entriesWithContent[i + j] = batchResults[j]; - } + return enrichedBlock; + }), + }; + + return { + ...entry, + diff: enrichedDiff, + }; + } catch (contentError) { + // If content fetch fails, return entry with error + return { + ...entry, + _contentFetchError: `Failed to fetch line content: ${contentError instanceof Error ? contentError.message : "Unknown error"}`, + }; + } + }) + ); + // Write batch results back into the array + for (let j = 0; j < batchResults.length; j++) { + entriesWithContent[i + j] = batchResults[j]; } - - enrichedChanges.changeEntries = entriesWithContent; } - return { - content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }], - }; - } catch (diffError) { - // If diff fetching fails, return metadata with error info - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - ...changes, - _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`, - _note: "Returned metadata only", - }, - null, - 2 - ), - }, - ], - }; + enrichedChanges.changeEntries = entriesWithContent; } + + return { + content: [{ type: "text", text: JSON.stringify(enrichedChanges, null, 2) }], + }; + } catch (diffError) { + // If diff fetching fails, return metadata with error info + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + ...changes, + _diffError: `Failed to fetch diff content: ${diffError instanceof Error ? diffError.message : "Unknown error"}`, + _note: "Returned metadata only", + }, + null, + 2 + ), + }, + ], + }; } } } @@ -1976,7 +1998,7 @@ function configureRepoTools(server: McpServer, tokenProvider: () => Promise Promise Promise Promise< const connection = await connectionProvider(); const testResultsApi = await connection.getTestResultsApi(); - // Build filter expression for outcomes if specified - const outcomeFilter = outcomes?.map((o) => `Outcome eq '${o}'`).join(" or "); + // Build filter expression for outcomes if specified. + // The API accepts: Outcome eq Failed,Passed (unquoted, comma-separated) + const outcomeFilter = outcomes?.length ? `Outcome eq ${outcomes.join(",")}` : undefined; // Fetch test result details for the build in a single API call // This is more efficient than getTestRuns + getTestResults per run, @@ -449,7 +450,9 @@ function configureTestPlanTools(server: McpServer, tokenProvider: () => Promise< if (testResultDetails.resultsForGroup) { for (const group of testResultDetails.resultsForGroup) { if (group.results) { - allResults.push(...group.results); + for (const result of group.results) { + allResults.push(result); + } } } } diff --git a/src/tools/wiki.ts b/src/tools/wiki.ts index 485111db..852bd053 100644 --- a/src/tools/wiki.ts +++ b/src/tools/wiki.ts @@ -122,7 +122,7 @@ function configureWikiTools(server: McpServer, tokenProvider: () => Promise Promise Promise { }); describe("elicitProject", () => { + const originalProjectEnv = process.env.ado_mcp_project; + + afterEach(() => { + if (originalProjectEnv === undefined) { + delete process.env.ado_mcp_project; + } else { + process.env.ado_mcp_project = originalProjectEnv; + } + }); + it("should use default message when no message is provided", async () => { + delete process.env.ado_mcp_project; (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([{ id: "proj-1", name: "ProjectAlpha" }]); const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; @@ -37,10 +48,48 @@ describe("elicitations", () => { expect(callArgs.message).toBe("Select the Azure DevOps project."); expect(result).toEqual({ resolved: "ProjectAlpha" }); }); + + it("should resolve to default project from ado_mcp_project env var without elicitation", async () => { + process.env.ado_mcp_project = "DefaultProject"; + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + + const result = await elicitProject(server, mockConnection as unknown as WebApi); + + expect(result).toEqual({ resolved: "DefaultProject" }); + expect(mockConnection.getCoreApi).not.toHaveBeenCalled(); + expect(mockCoreApi.getProjects).not.toHaveBeenCalled(); + expect(elicitMock).not.toHaveBeenCalled(); + }); + + it("should not use empty ado_mcp_project env var as default", async () => { + process.env.ado_mcp_project = ""; + (mockCoreApi.getProjects as jest.Mock).mockResolvedValue([{ id: "proj-1", name: "ProjectAlpha" }]); + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + elicitMock.mockResolvedValue({ action: "accept", content: { project: "ProjectAlpha" } }); + + const result = await elicitProject(server, mockConnection as unknown as WebApi); + + expect(mockCoreApi.getProjects).toHaveBeenCalled(); + expect(elicitMock).toHaveBeenCalled(); + expect(result).toEqual({ resolved: "ProjectAlpha" }); + }); }); describe("elicitTeam", () => { + const originalTeamEnv = process.env.ado_mcp_team; + + afterEach(() => { + if (originalTeamEnv === undefined) { + delete process.env.ado_mcp_team; + } else { + process.env.ado_mcp_team = originalTeamEnv; + } + }); + it("should use default message when no message is provided", async () => { + delete process.env.ado_mcp_team; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]); const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; @@ -54,6 +103,7 @@ describe("elicitations", () => { }); it("should fall back to team id when name is missing", async () => { + delete process.env.ado_mcp_team; (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([ { id: "team-1", name: undefined }, { id: undefined, name: undefined }, @@ -71,5 +121,32 @@ describe("elicitations", () => { expect(oneOf[1]).toEqual({ const: "", title: "Unknown team" }); expect(result).toEqual({ resolved: "team-1" }); }); + + it("should resolve to default team from ado_mcp_team env var without elicitation", async () => { + process.env.ado_mcp_team = "DefaultTeam"; + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + + const result = await elicitTeam(server, mockConnection as unknown as WebApi, "ProjectAlpha"); + + expect(result).toEqual({ resolved: "DefaultTeam" }); + expect(mockConnection.getCoreApi).not.toHaveBeenCalled(); + expect(mockCoreApi.getTeams).not.toHaveBeenCalled(); + expect(elicitMock).not.toHaveBeenCalled(); + }); + + it("should not use empty ado_mcp_team env var as default", async () => { + process.env.ado_mcp_team = ""; + (mockCoreApi.getTeams as jest.Mock).mockResolvedValue([{ id: "team-1", name: "Team One" }]); + + const elicitMock = (server as unknown as { server: { elicitInput: jest.Mock } }).server.elicitInput as jest.Mock; + elicitMock.mockResolvedValue({ action: "accept", content: { team: "Team One" } }); + + const result = await elicitTeam(server, mockConnection as unknown as WebApi, "ProjectAlpha"); + + expect(mockCoreApi.getTeams).toHaveBeenCalled(); + expect(elicitMock).toHaveBeenCalled(); + expect(result).toEqual({ resolved: "Team One" }); + }); }); }); diff --git a/test/src/tools/pipelines.test.ts b/test/src/tools/pipelines.test.ts index 5d697a6d..803311a7 100644 --- a/test/src/tools/pipelines.test.ts +++ b/test/src/tools/pipelines.test.ts @@ -25,7 +25,7 @@ describe("configurePipelineTools", () => { let tokenProvider: TokenProviderMock; let connectionProvider: ConnectionProviderMock; let userAgentProvider: () => string; - let mockConnection: { getBuildApi: jest.Mock; getPipelinesApi: jest.Mock; serverUrl: string }; + let mockConnection: { getBuildApi: jest.Mock; getPipelinesApi: jest.Mock; getGitApi: jest.Mock; serverUrl: string }; beforeEach(() => { server = { tool: jest.fn() } as unknown as McpServer; @@ -34,6 +34,7 @@ describe("configurePipelineTools", () => { mockConnection = { getBuildApi: jest.fn(), getPipelinesApi: jest.fn(), + getGitApi: jest.fn(), serverUrl: "https://dev.azure.com/test-org", }; connectionProvider = jest.fn().mockResolvedValue(mockConnection); @@ -380,7 +381,7 @@ describe("configurePipelineTools", () => { const params = { project: "test-project", - repositoryId: "repo-123", + repositoryId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", repositoryType: "TfsGit" as const, name: "test-build", top: 10, @@ -391,7 +392,7 @@ describe("configurePipelineTools", () => { expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( "test-project", "test-build", - "repo-123", + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "TfsGit", undefined, // queryOrder 10, // top @@ -435,6 +436,183 @@ describe("configurePipelineTools", () => { await expect(handler(params)).rejects.toThrow("API Error"); }); + + it("should auto-resolve repository name to GUID for TfsGit", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockResolvedValue([ + { id: "resolved-guid-1234", name: "my-repo" }, + { id: "other-guid-5678", name: "other-repo" }, + ]), + }; + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([{ id: 1, name: "Build" }]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "my-repo", + }; + + const result = await handler(params); + + expect(mockGitApi.getRepositories).toHaveBeenCalledWith("test-project"); + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, // name + "resolved-guid-1234", // resolved repositoryId + undefined, // repositoryType + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should return error when repository name is not found", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockResolvedValue([{ id: "some-guid", name: "other-repo" }]), + }; + const mockBuildApi = { + getDefinitions: jest.fn(), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "nonexistent-repo", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("nonexistent-repo"); + expect(result.content[0].text).toContain("not found"); + expect(mockBuildApi.getDefinitions).not.toHaveBeenCalled(); + }); + + it("should pass GUID repositoryId through without resolution", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + + const params = { + project: "test-project", + repositoryId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + }; + + const result = await handler(params); + + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should not resolve repository name for GitHub repositoryType", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockBuildApi = { + getDefinitions: jest.fn().mockResolvedValue([]), + }; + mockConnection.getBuildApi.mockResolvedValue(mockBuildApi); + + const params = { + project: "test-project", + repositoryId: "owner/repo", + repositoryType: "GitHub" as const, + }; + + const result = await handler(params); + + // Should pass through without attempting resolution + expect(mockBuildApi.getDefinitions).toHaveBeenCalledWith( + "test-project", + undefined, + "owner/repo", + "GitHub", + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ); + expect(result.isError).toBeFalsy(); + }); + + it("should propagate error when getRepositories fails during name resolution", async () => { + configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_get_build_definitions"); + if (!call) throw new Error("pipelines_get_build_definitions tool not registered"); + const [, , , handler] = call; + + const mockGitApi = { + getRepositories: jest.fn().mockRejectedValue(new Error("Project access denied")), + }; + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + + const params = { + project: "test-project", + repositoryId: "my-repo", + }; + + await expect(handler(params)).rejects.toThrow("Project access denied"); + }); }); describe("get_definition_revisions tool", () => { @@ -1507,7 +1685,7 @@ describe("configurePipelineTools", () => { destinationPath: "C:\\temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); @@ -1524,7 +1702,7 @@ describe("configurePipelineTools", () => { destinationPath: "/tmp/artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); @@ -1541,11 +1719,18 @@ describe("configurePipelineTools", () => { destinationPath: "..\\..\\temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); - it("should reject artifactName with path traversal segments", async () => { + it.each([ + ["path traversal segments", "..\\..\\drop"], + ["Windows path separators", "folder\\drop"], + ["Unix path separators", "folder/drop"], + ["Windows absolute path", "C:\\temp\\drop"], + ["Unix absolute path", "/tmp/drop"], + ["current directory segment", "."], + ])("should reject artifactName with %s", async (_description, artifactName) => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); if (!call) throw new Error("pipelines_download_artifact tool not registered"); @@ -1554,49 +1739,25 @@ describe("configurePipelineTools", () => { const params = { project: "test-project", buildId: 12345, - artifactName: "..\\..\\drop", + artifactName, destinationPath: "temp\\artifacts", }; - await expect(handler(params)).rejects.toThrow("Invalid artifactName: path traversal is not allowed."); - expect(connectionProvider).not.toHaveBeenCalled(); - }); - - it("should reject destinationPath with a Windows drive-relative path", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); - if (!call) throw new Error("pipelines_download_artifact tool not registered"); - const [, , , handler] = call; - - const params = { - project: "test-project", - buildId: 12345, - artifactName: "drop", - destinationPath: "D:artifacts", - }; - - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); - expect(connectionProvider).not.toHaveBeenCalled(); - }); - - it("should reject destinationPath with a drive-relative path with subdirectory", async () => { - configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); - const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); - if (!call) throw new Error("pipelines_download_artifact tool not registered"); - const [, , , handler] = call; - - const params = { - project: "test-project", - buildId: 12345, - artifactName: "drop", - destinationPath: "E:sub\\deep", - }; - - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid artifactName: artifactName must be a file name, not a path."); expect(connectionProvider).not.toHaveBeenCalled(); }); - it("should reject destinationPath with only a drive letter and colon", async () => { + it.each([ + ["Windows drive-relative path", "D:artifacts"], + ["Windows drive-relative path with subdirectory", "E:sub\\deep"], + ["only a drive letter and colon", "D:"], + ["Windows root-relative path", "\\temp\\artifacts"], + ["Windows UNC path", "\\\\server\\share\\artifacts"], + ["Windows extended-length path", "\\\\?\\C:\\temp\\artifacts"], + ["segment-level traversal", "temp\\..\\artifacts"], + ["current directory segment", "."], + ["segment-level current directory", "temp\\.\\artifacts"], + ])("should reject destinationPath with %s", async (_description, destinationPath) => { configurePipelineTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "pipelines_download_artifact"); if (!call) throw new Error("pipelines_download_artifact tool not registered"); @@ -1606,10 +1767,10 @@ describe("configurePipelineTools", () => { project: "test-project", buildId: 12345, artifactName: "drop", - destinationPath: "D:", + destinationPath, }; - await expect(handler(params)).rejects.toThrow("Invalid destinationPath: absolute paths and path traversals are not allowed."); + await expect(handler(params)).rejects.toThrow("Invalid destinationPath: use a relative path without path traversal."); expect(connectionProvider).not.toHaveBeenCalled(); }); diff --git a/test/src/tools/repositories.test.ts b/test/src/tools/repositories.test.ts index 3d2b21f0..9a7e67e5 100644 --- a/test/src/tools/repositories.test.ts +++ b/test/src/tools/repositories.test.ts @@ -492,6 +492,63 @@ describe("repos tools", () => { expect(parsedResult.pullRequestId).toBe(123); }); + it("should set merge commit message when autocomplete is enabled", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.update_pull_request); + if (!call) throw new Error("repo_update_pull_request tool not registered"); + const [, , , handler] = call; + + const mockUpdatedPR = { + pullRequestId: 123, + title: "Updated PR", + autoCompleteSetBy: { id: "user-id" }, + completionOptions: { + mergeStrategy: 2, // Squash + deleteSourceBranch: true, + transitionWorkItems: false, + bypassPolicy: false, + mergeCommitMessage: "Merged PR 123: Update dependencies", + }, + }; + + mockGitApi.updatePullRequest.mockResolvedValue(mockUpdatedPR); + mockGetCurrentUserDetails.mockResolvedValue({ + authenticatedUser: { id: "current-user-id" }, + authorizedUser: { id: "current-user-id" }, + }); + + const params = { + repositoryId: "test-repo-id", + pullRequestId: 123, + project: "test-project", + autoComplete: true, + mergeStrategy: "Squash", + mergeCommitMessage: "Merged PR 123: Update dependencies", + deleteSourceBranch: true, + transitionWorkItems: false, + }; + + const result = await handler(params); + + expect(mockGitApi.updatePullRequest).toHaveBeenCalledWith( + expect.objectContaining({ + autoCompleteSetBy: { id: "current-user-id" }, + completionOptions: expect.objectContaining({ + mergeStrategy: 2, // GitPullRequestMergeStrategy.Squash + mergeCommitMessage: "Merged PR 123: Update dependencies", + deleteSourceBranch: true, + transitionWorkItems: false, + bypassPolicy: false, + }), + }), + "test-repo-id", + 123, + "test-project" + ); + expect(result.isError).toBeFalsy(); + }); + it("should disable autocomplete when autoComplete is false", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); @@ -1127,7 +1184,7 @@ describe("repos tools", () => { expect(result.content[0].text).toBe(JSON.stringify(expectedTrimmedPR, null, 2)); }); - it("should return no-data message when createPullRequest returns null and fallback finds no PRs", async () => { + it("should return error when createPullRequest returns null and fallback finds no PRs", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.create_pull_request); @@ -1146,8 +1203,9 @@ describe("repos tools", () => { const result = await handler(params); - expect(result.content[0].text).toBe("Pull request created but API returned no data."); - expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toContain('repositoryId="repo123"'); + expect(result.content[0].text).toContain("repo_list_repos_by_project"); + expect(result.isError).toBe(true); }); }); @@ -4802,6 +4860,390 @@ describe("repos tools", () => { expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch target file content"); expect(parsedResult.changeEntries[0]._contentFetchError).toContain("TF401175"); }); + + it("should return file content for PRs with only added files (no modified files)", async () => { + // Regression test: when all changes are Add, fileDiffParams is empty, so getFileDiffs + // was never called and the code fell through to the metadata-only fallback, losing the + // includeLineContent enrichment for added files. + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [ + { item: { path: "/Testfolder.md" }, originalPath: null, changeType: 1 }, // Add + { item: { path: "/New Folder/Addition 1" }, originalPath: null, changeType: 1 }, // Add + { item: { path: "/New Folder/Addition 2" }, originalPath: null, changeType: 1 }, // Add + ], + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const makeStream = (content: string) => { + const s = new Readable(); + s.push(content); + s.push(null); + return s; + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(makeStream("# Testfolder\nHello")).mockResolvedValueOnce(makeStream("Addition 1 content")).mockResolvedValueOnce(makeStream("Addition 2 content")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + // getFileDiffs must NOT have been called (no modified files) + expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); + // getItemText must have been called once per added file + expect(mockGitApi.getItemText).toHaveBeenCalledTimes(3); + + const parsedResult = JSON.parse(result.content[0].text); + // Each added entry should have a synthetic diff with the full file content, + // path set to the new file path, and originalPath null (file didn't exist before). + expect(parsedResult.changeEntries[0].diff.path).toBe("Testfolder.md"); + expect(parsedResult.changeEntries[0].diff.originalPath).toBeNull(); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].modifiedLines).toEqual(["# Testfolder", "Hello"]); + expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 1 content"]); + expect(parsedResult.changeEntries[2].diff.lineDiffBlocks[0].modifiedLines).toEqual(["Addition 2 content"]); + }); + + it("should return file content for PRs with only deleted files (no modified files)", async () => { + // Regression test: mirror of the addition case for deletions — when all changes are Delete, + // fileDiffParams is empty so the enrichment block was previously skipped entirely. + // Also covers the ADO behaviour where item.path is null for deletions (path lives in originalPath). + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + // ADO real shape: item.path is null for deletions; path is in originalPath only + const mockChanges = { + changeEntries: [ + { originalPath: "/src/removed.ts", item: { path: null }, changeType: 16 }, // Delete + { originalPath: "/src/gone.ts", item: { path: null }, changeType: 16 }, // Delete + ], + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const makeStream = (content: string) => { + const s = new Readable(); + s.push(content); + s.push(null); + return s; + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(makeStream("export const removed = true;")).mockResolvedValueOnce(makeStream("export const gone = true;")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + // getFileDiffs must NOT have been called (no modified files) + expect(mockGitApi.getFileDiffs).not.toHaveBeenCalled(); + // getItemText must have been called once per deleted file, using the normalised originalPath + expect(mockGitApi.getItemText).toHaveBeenCalledTimes(2); + expect(mockGitApi.getItemText).toHaveBeenCalledWith( + "12345678-1234-1234-1234-123456789012", + "src/removed.ts", // leading slash stripped + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { version: "def456", versionType: expect.anything() } + ); + + const parsedResult = JSON.parse(result.content[0].text); + // Each deleted entry should have a synthetic diff with the removed file content, + // path null (file no longer exists) and originalPath set to the pre-deletion path. + expect(parsedResult.changeEntries[0].diff.path).toBeNull(); + expect(parsedResult.changeEntries[0].diff.originalPath).toBe("src/removed.ts"); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].originalLines).toEqual(["export const removed = true;"]); + expect(parsedResult.changeEntries[0].diff.lineDiffBlocks[0].changeType).toBe(2); // Delete + expect(parsedResult.changeEntries[1].diff.path).toBeNull(); + expect(parsedResult.changeEntries[1].diff.originalPath).toBe("src/gone.ts"); + expect(parsedResult.changeEntries[1].diff.lineDiffBlocks[0].originalLines).toEqual(["export const gone = true;"]); + }); + + it("should return error when non-GUID repositoryId is used without a project", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const params = { + repositoryId: "my-repository-name", // not a GUID + pullRequestId: 456, + iterationId: 1, + // project intentionally omitted + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("When using a repository name instead of a GUID"); + expect(mockGitApi.getPullRequestIterationChanges).not.toHaveBeenCalled(); + }); + + it("should return entry unchanged when getItemText rejects for added file (catch null path)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add + nextSkip: 0, + nextTop: 0, + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + // getItemText rejects → .catch(() => null) fires → targetStream = null → return entry + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0].diff).toBeNull(); + expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); + }); + + it("should return _contentFetchError when streamToString throws for added file", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/new-file.ts" }, originalPath: null, changeType: 1 }], // Add + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const errorStream = new Readable({ + read() { + this.emit("error", new Error("Stream read error")); + }, + }); + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(errorStream); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch added file content"); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); + }); + + it("should return entry unchanged when getItemText rejects for deleted file (catch null path)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete + nextSkip: 0, + nextTop: 0, + }; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + // getItemText rejects → .catch(() => null) fires → baseStream = null → return entry + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0].diff).toBeNull(); + expect(parsedResult.changeEntries[0]._contentFetchError).toBeUndefined(); + }); + + it("should return _contentFetchError when streamToString throws for deleted file", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ originalPath: "/deleted-file.ts", item: { path: null }, changeType: 16 }], // Delete + nextSkip: 0, + nextTop: 0, + }; + + const { Readable } = await import("stream"); + const errorStream = new Readable({ + read() { + this.emit("error", new Error("Stream read error")); + }, + }); + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getItemText.mockResolvedValueOnce(errorStream); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Failed to fetch deleted file content"); + expect(parsedResult.changeEntries[0]._contentFetchError).toContain("Stream read error"); + }); + + it("should handle getItemText rejection for modified file via catch null (empty lines result)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.get_pull_request_changes); + if (!call) throw new Error("repo_get_pull_request_changes tool not registered"); + const [, , , handler] = call; + + const mockIteration = { + id: 1, + sourceRefCommit: { commitId: "abc123" }, + commonRefCommit: { commitId: "def456" }, + }; + + const mockChanges = { + changeEntries: [{ item: { path: "/src/file.ts" }, changeType: 2 }], // Edit + nextSkip: 0, + nextTop: 0, + }; + + const mockFileDiffs = [ + { + path: "src/file.ts", + lineDiffBlocks: [ + { + changeType: 3, + modifiedLineNumberStart: 10, + modifiedLinesCount: 1, + originalLineNumberStart: 10, + originalLinesCount: 1, + }, + ], + }, + ]; + + mockGitApi.getPullRequestIteration.mockResolvedValue(mockIteration); + mockGitApi.getPullRequestIterationChanges.mockResolvedValue(mockChanges); + mockGitApi.getFileDiffs.mockResolvedValue(mockFileDiffs); + // Both getItemText calls reject → .catch(() => null) fires → null content → empty lines + mockGitApi.getItemText.mockRejectedValueOnce(new Error("Network error")).mockRejectedValueOnce(new Error("Network error")); + + const params = { + repositoryId: "12345678-1234-1234-1234-123456789012", + pullRequestId: 456, + iterationId: 1, + includeDiffs: true, + includeLineContent: true, + }; + + const result = await handler(params); + + const parsedResult = JSON.parse(result.content[0].text); + const diffBlock = parsedResult.changeEntries[0].diff.lineDiffBlocks[0]; + expect(diffBlock.originalLines).toEqual([]); + expect(diffBlock.modifiedLines).toEqual([]); + }); }); describe("repo_reply_to_comment", () => { @@ -7879,7 +8321,7 @@ describe("repos tools", () => { ); }); - it("should return no items found message", async () => { + it("should return isError when no items found (empty array)", async () => { configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); const [, , , handler] = call; @@ -7889,7 +8331,44 @@ describe("repos tools", () => { const result = await handler({ repositoryId: "repo123", path: "/missing" }); expect(result).toEqual({ - content: [{ type: "text", text: "No items found at path: /missing" }], + content: [{ type: "text", text: "No items found at path: /missing. The path may not exist in the repository." }], + isError: true, + }); + }); + + it("should succeed for empty directory (folder entry only)", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const [, , , handler] = call; + + const items = [ + { + path: "/empty-dir", + isFolder: true, + gitObjectType: 2, + commitId: "abc123", + }, + ]; + mockGitApi.getItems.mockResolvedValue(items); + + const result = await handler({ repositoryId: "repo123", path: "/empty-dir" }); + + expect(result.isError).toBeFalsy(); + expect(result.content[0].text).toContain('"count": 1'); + }); + + it("should return isError when getItems returns null", async () => { + configureRepoTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === REPO_TOOLS.list_directory); + const [, , , handler] = call; + + mockGitApi.getItems.mockResolvedValue(null); + + const result = await handler({ repositoryId: "repo123", path: "/nonexistent" }); + + expect(result).toEqual({ + content: [{ type: "text", text: "No items found at path: /nonexistent. The path may not exist in the repository." }], + isError: true, }); }); }); diff --git a/test/src/tools/test-plan.test.ts b/test/src/tools/test-plan.test.ts index dad46ce2..139fac93 100644 --- a/test/src/tools/test-plan.test.ts +++ b/test/src/tools/test-plan.test.ts @@ -752,11 +752,7 @@ describe("configureTestPlanTools", () => { const [, , , handler] = call; (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ - resultsForGroup: [ - { - results: [{ id: 1, testCaseTitle: "FailingTest", outcome: "Failed", errorMessage: "error" }], - }, - ], + resultsForGroup: [], }); await handler({ project: "proj1", buildid: 123, outcomes: ["Failed", "Aborted"] }); @@ -766,7 +762,7 @@ describe("configureTestPlanTools", () => { 123, undefined, // publishContext undefined, // groupBy - "Outcome eq 'Failed' or Outcome eq 'Aborted'", // filter expression + "Outcome eq Failed,Aborted", // filter expression undefined, // orderby true // shouldIncludeResults ); @@ -848,6 +844,30 @@ describe("configureTestPlanTools", () => { }); }); + it("should handle large result groups without spreading them onto the stack", async () => { + configureTestPlanTools(server, tokenProvider, connectionProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); + if (!call) throw new Error("testplan_show_test_results_from_build_id tool not registered"); + const [, , , handler] = call; + + const largeResults = Array.from({ length: 150_000 }, (_, id) => ({ + id, + testCaseTitle: `Test ${id}`, + outcome: "Passed", + })); + + (mockTestResultsApi.getTestResultDetailsForBuild as jest.Mock).mockResolvedValue({ + resultsForGroup: [{ results: largeResults }], + }); + + const result = await handler({ project: "proj1", buildid: 456 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveLength(largeResults.length); + expect(parsed[0].testCaseTitle).toBe("Test 0"); + expect(parsed[largeResults.length - 1].testCaseTitle).toBe("Test 149999"); + }); + it("should handle empty results groups without errors", async () => { configureTestPlanTools(server, tokenProvider, connectionProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "testplan_show_test_results_from_build_id"); diff --git a/test/src/tools/wiki.test.ts b/test/src/tools/wiki.test.ts index 622a3248..a7a43ec3 100644 --- a/test/src/tools/wiki.test.ts +++ b/test/src/tools/wiki.test.ts @@ -480,6 +480,28 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("Error fetching wiki page metadata: Network error"); }); + it("should handle non-Error throwables in metadata catch block", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); + if (!call) throw new Error("wiki_get_page tool not registered"); + const [, , , handler] = call; + + mockFetch.mockImplementation(() => { + throw "string thrown, not an Error"; + }); + + const params = { + wikiIdentifier: "wiki1", + project: "proj1", + path: "/Home", + }; + + const result = await handler(params); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error fetching wiki page metadata: Unknown error occurred"); + }); + it("should encode project and wikiIdentifier in URL to prevent path injection", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page"); @@ -854,6 +876,81 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("UNTRUSTED"); }); + it("should fall back to getPageText when pageId fetch returns non-404 error status", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as typeof fetch; + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("fallback after server error")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("fallback after server error"); + }); + + it("should normalize pagePath query parameter when it lacks a leading slash", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("normalized path content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki?pagePath=Home"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/Home", undefined, undefined, true); + expect(result.content[0].text).toContain("normalized path content"); + }); + + it("should default to root path when URL has no segments after wikiIdentifier and no pagePath", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("bare-wiki root content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("bare-wiki root content"); + }); + it("should use default root path when resolvedPath is undefined", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); @@ -878,7 +975,7 @@ describe("configureWikiTools", () => { expect(result.isError).toBeUndefined(); }); - it("should handle scenario where resolvedProject/Wiki become null after URL processing", async () => { + it("should return URL parse error for malformed wiki URL with empty project and wiki segments", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); if (!call) throw new Error("wiki_get_page_content tool not registered"); @@ -900,6 +997,51 @@ describe("configureWikiTools", () => { expect(result.content[0].text).toContain("URL does not match expected wiki pattern"); }); + it("should return parse error when wiki URL is missing the wikiIdentifier segment", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + const url = "https://dev.azure.com/proj/_wiki/wikis/"; + const result = await handler({ url }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Could not extract project or wikiIdentifier from URL"); + }); + + it("should fall back to getPageText when REST page-by-id returns null JSON body", async () => { + configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); + const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content"); + if (!call) throw new Error("wiki_get_page_content tool not registered"); + const [, , , handler] = call; + + (tokenProvider as jest.Mock).mockResolvedValueOnce("abc"); + + const mockFetch = jest.fn(); + global.fetch = mockFetch as typeof fetch; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValue(null), + }); + + const mockStream = { + setEncoding: jest.fn(), + on: function (event: string, cb: (chunk?: unknown) => void) { + if (event === "data") setImmediate(() => cb("fallback root content")); + if (event === "end") setImmediate(() => cb()); + return this; + }, + }; + mockWikiApi.getPageText.mockResolvedValue(mockStream as unknown); + + const url = "https://dev.azure.com/org/project/_wiki/wikis/myWiki/123/Page-Title"; + const result = await handler({ url }); + + expect(mockWikiApi.getPageText).toHaveBeenCalledWith("project", "myWiki", "/", undefined, undefined, true); + expect(result.content[0].text).toContain("fallback root content"); + }); + it("should encode project and wikiIdentifier in REST URL to prevent path injection", async () => { configureWikiTools(server, tokenProvider, connectionProvider, userAgentProvider); const call = (server.tool as jest.Mock).mock.calls.find(([toolName]) => toolName === "wiki_get_page_content");