This is page 6 of 8. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .clinerules ├── .env.example ├── .eslintrc.json ├── .github │ ├── FUNDING.yml │ ├── release-please-config.json │ ├── release-please-manifest.json │ └── workflows │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky │ ├── commit-msg │ └── pre-commit ├── .kilocode │ └── mcp.json ├── .prettierrc ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── commitlint.config.js ├── CONTRIBUTING.md ├── create_branch.sh ├── docs │ ├── authentication.md │ ├── azure-identity-authentication.md │ ├── ci-setup.md │ ├── examples │ │ ├── azure-cli-authentication.env │ │ ├── azure-identity-authentication.env │ │ ├── pat-authentication.env │ │ └── README.md │ ├── testing │ │ ├── README.md │ │ └── setup.md │ └── tools │ ├── core-navigation.md │ ├── organizations.md │ ├── pipelines.md │ ├── projects.md │ ├── pull-requests.md │ ├── README.md │ ├── repositories.md │ ├── resources.md │ ├── search.md │ ├── user-tools.md │ ├── wiki.md │ └── work-items.md ├── finish_task.sh ├── jest.e2e.config.js ├── jest.int.config.js ├── jest.unit.config.js ├── LICENSE ├── memory │ └── tasks_memory_2025-05-26T16-18-03.json ├── package-lock.json ├── package.json ├── project-management │ ├── planning │ │ ├── architecture-guide.md │ │ ├── azure-identity-authentication-design.md │ │ ├── project-plan.md │ │ ├── project-structure.md │ │ ├── tech-stack.md │ │ └── the-dream-team.md │ ├── startup.xml │ ├── tdd-cycle.xml │ └── troubleshooter.xml ├── README.md ├── setup_env.sh ├── shrimp-rules.md ├── src │ ├── clients │ │ └── azure-devops.ts │ ├── features │ │ ├── organizations │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-organizations │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pipelines │ │ │ ├── get-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pipelines │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── trigger-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── types.ts │ │ ├── projects │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-project │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-project-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-projects │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pull-requests │ │ │ ├── add-pull-request-comment │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── create-pull-request │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-pull-request-comments │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pull-requests │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── types.ts │ │ │ └── update-pull-request │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-all-repositories-tree │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── feature.spec.unit.ts.snap │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-file-content │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-repositories │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── search │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── search-code │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-work-items │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── users │ │ │ ├── get-me │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── wikis │ │ │ ├── create-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── create-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wikis │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-wiki-pages │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ └── update-wiki-page │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── work-items │ │ ├── __test__ │ │ │ ├── fixtures.ts │ │ │ ├── test-helpers.ts │ │ │ └── test-utils.ts │ │ ├── create-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── get-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── index.spec.unit.ts │ │ ├── index.ts │ │ ├── list-work-items │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── manage-work-item-link │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── schemas.ts │ │ ├── tool-definitions.ts │ │ ├── types.ts │ │ └── update-work-item │ │ ├── feature.spec.int.ts │ │ ├── feature.spec.unit.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── index.spec.unit.ts │ ├── index.ts │ ├── server.spec.e2e.ts │ ├── server.ts │ ├── shared │ │ ├── api │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── auth-factory.ts │ │ │ ├── client-factory.ts │ │ │ └── index.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ └── version.ts │ │ ├── enums │ │ │ ├── index.spec.unit.ts │ │ │ └── index.ts │ │ ├── errors │ │ │ ├── azure-devops-errors.ts │ │ │ ├── handle-request-error.ts │ │ │ └── index.ts │ │ ├── test │ │ │ └── test-helpers.ts │ │ └── types │ │ ├── config.ts │ │ ├── index.ts │ │ ├── request-handler.ts │ │ └── tool-definition.ts │ └── utils │ ├── environment.spec.unit.ts │ └── environment.ts ├── tasks.json ├── tests │ └── setup.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /setup_env.sh: -------------------------------------------------------------------------------- ```bash 1 | #!/bin/bash 2 | 3 | # Global variable to track if an error has occurred 4 | ERROR_OCCURRED=0 5 | 6 | # Function to handle errors without exiting the shell when sourced 7 | handle_error() { 8 | local message=$1 9 | local reset_colors="\033[0m" 10 | echo -e "\033[0;31m$message$reset_colors" 11 | 12 | # Set the error flag 13 | ERROR_OCCURRED=1 14 | 15 | # If script is being sourced (. or source) 16 | if [[ "${BASH_SOURCE[0]}" != "${0}" ]] || [[ -n "$ZSH_VERSION" && "$ZSH_EVAL_CONTEXT" == *:file:* ]]; then 17 | echo "Script terminated with error. Returning to shell." 18 | # Reset colors to ensure shell isn't affected 19 | echo -e "$reset_colors" 20 | # The return will be caught by the caller 21 | return 1 22 | else 23 | # If script is being executed directly 24 | exit 1 25 | fi 26 | } 27 | 28 | # Function to check if we should continue after potential error points 29 | should_continue() { 30 | if [ $ERROR_OCCURRED -eq 1 ]; then 31 | # Reset colors to ensure shell isn't affected 32 | echo -e "\033[0m" 33 | return 1 34 | fi 35 | return 0 36 | } 37 | 38 | # Ensure script is running with a compatible shell 39 | if [ -z "$BASH_VERSION" ] && [ -z "$ZSH_VERSION" ]; then 40 | handle_error "This script requires bash or zsh to run. Please run it with: bash $(basename "$0") or zsh $(basename "$0")" 41 | return 1 2>/dev/null || exit 1 42 | fi 43 | 44 | # Set shell options for compatibility 45 | if [ -n "$ZSH_VERSION" ]; then 46 | # ZSH specific settings 47 | setopt SH_WORD_SPLIT 48 | setopt KSH_ARRAYS 49 | fi 50 | 51 | # Colors for better output - ensure they're properly reset after use 52 | GREEN='\033[0;32m' 53 | YELLOW='\033[0;33m' 54 | RED='\033[0;31m' 55 | NC='\033[0m' # No Color 56 | 57 | echo -e "${GREEN}Azure DevOps MCP Server - Environment Setup${NC}" 58 | echo "This script will help you set up your .env file with Azure DevOps credentials." 59 | echo 60 | 61 | # Clean up any existing create_pat.json file 62 | if [ -f "create_pat.json" ]; then 63 | echo -e "${YELLOW}Cleaning up existing create_pat.json file...${NC}" 64 | rm -f create_pat.json 65 | fi 66 | 67 | # Check if Azure CLI is installed 68 | if ! command -v az &> /dev/null; then 69 | handle_error "Error: Azure CLI is not installed.\nPlease install Azure CLI first: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli" 70 | return 1 2>/dev/null || exit 1 71 | fi 72 | should_continue || return 1 2>/dev/null || exit 1 73 | 74 | # Check if Azure DevOps extension is installed 75 | echo -e "${YELLOW}Checking for Azure DevOps extension...${NC}" 76 | az devops &> /dev/null 77 | if [ $? -ne 0 ]; then 78 | echo "Azure DevOps extension not found. Installing..." 79 | az extension add --name azure-devops 80 | if [ $? -ne 0 ]; then 81 | handle_error "Failed to install Azure DevOps extension." 82 | return 1 2>/dev/null || exit 1 83 | else 84 | echo -e "${GREEN}Azure DevOps extension installed successfully.${NC}" 85 | fi 86 | else 87 | echo "Azure DevOps extension is already installed." 88 | fi 89 | should_continue || return 1 2>/dev/null || exit 1 90 | 91 | # Check if jq is installed 92 | if ! command -v jq &> /dev/null; then 93 | handle_error "Error: jq is not installed.\nPlease install jq first. On Ubuntu/Debian: sudo apt-get install jq\nOn macOS: brew install jq" 94 | return 1 2>/dev/null || exit 1 95 | fi 96 | should_continue || return 1 2>/dev/null || exit 1 97 | 98 | # Check if already logged in 99 | echo -e "\n${YELLOW}Step 1: Checking Azure CLI authentication...${NC}" 100 | if ! az account show &> /dev/null; then 101 | echo "Not logged in. Initiating login..." 102 | az login --allow-no-subscriptions 103 | if [ $? -ne 0 ]; then 104 | handle_error "Failed to login to Azure CLI." 105 | return 1 2>/dev/null || exit 1 106 | fi 107 | else 108 | echo -e "${GREEN}Already logged in to Azure CLI.${NC}" 109 | fi 110 | should_continue || return 1 2>/dev/null || exit 1 111 | 112 | # Get Azure DevOps Organizations using REST API 113 | echo -e "\n${YELLOW}Step 2: Fetching your Azure DevOps organizations...${NC}" 114 | echo "This may take a moment..." 115 | 116 | # First get the user profile 117 | echo "Getting user profile..." 118 | profile_response=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798" 2>&1) 119 | profile_status=$? 120 | 121 | if [ $profile_status -ne 0 ]; then 122 | echo -e "${RED}Error: Failed to get user profile${NC}" 123 | echo -e "${RED}Status code: $profile_status${NC}" 124 | echo -e "${RED}Error response:${NC}" 125 | echo "$profile_response" 126 | echo 127 | echo "Manually provide your organization name instead." 128 | read -p "Enter your Azure DevOps organization name: " org_name 129 | else 130 | echo "Profile API response:" 131 | echo "$profile_response" 132 | echo 133 | public_alias=$(echo "$profile_response" | jq -r '.publicAlias') 134 | 135 | if [ "$public_alias" = "null" ] || [ -z "$public_alias" ]; then 136 | echo -e "${RED}Failed to extract publicAlias from response.${NC}" 137 | echo "Full response was:" 138 | echo "$profile_response" 139 | echo 140 | echo "Manually provide your organization name instead." 141 | read -p "Enter your Azure DevOps organization name: " org_name 142 | else 143 | # Get organizations using the publicAlias 144 | echo "Fetching organizations..." 145 | orgs_result=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/accounts?memberId=$public_alias&api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798") 146 | 147 | # Extract organization names from the response using jq 148 | orgs=$(echo "$orgs_result" | jq -r '.value[].accountName') 149 | 150 | if [ -z "$orgs" ]; then 151 | echo -e "${RED}No organizations found.${NC}" 152 | echo "Manually provide your organization name instead." 153 | read -p "Enter your Azure DevOps organization name: " org_name 154 | else 155 | # Display organizations for selection 156 | echo -e "\nYour Azure DevOps organizations:" 157 | i=1 158 | OLDIFS=$IFS 159 | IFS=$'\n' 160 | # Create array in a shell-agnostic way 161 | orgs_array=() 162 | while IFS= read -r line; do 163 | [ -n "$line" ] && orgs_array+=("$line") 164 | done <<< "$orgs" 165 | IFS=$OLDIFS 166 | 167 | # Check if array is empty 168 | if [ ${#orgs_array[@]} -eq 0 ]; then 169 | echo -e "${RED}Failed to parse organizations list.${NC}" 170 | echo "Manually provide your organization name instead." 171 | read -p "Enter your Azure DevOps organization name: " org_name 172 | else 173 | # Display organizations with explicit indexing 174 | for ((idx=0; idx<${#orgs_array[@]}; idx++)); do 175 | echo "$((idx+1)) ${orgs_array[$idx]}" 176 | done 177 | 178 | # Prompt for selection 179 | read -p "Select an organization (1-${#orgs_array[@]}): " org_selection 180 | 181 | if [[ "$org_selection" =~ ^[0-9]+$ ]] && [ "$org_selection" -ge 1 ] && [ "$org_selection" -le "${#orgs_array[@]}" ]; then 182 | org_name=${orgs_array[$((org_selection-1))]} 183 | else 184 | handle_error "Invalid selection. Please run the script again." 185 | return 1 2>/dev/null || exit 1 186 | fi 187 | fi 188 | fi 189 | fi 190 | fi 191 | should_continue || return 1 2>/dev/null || exit 1 192 | 193 | org_url="https://dev.azure.com/$org_name" 194 | echo -e "${GREEN}Using organization URL: $org_url${NC}" 195 | 196 | # Get Default Project (Optional) 197 | echo -e "\n${YELLOW}Step 3: Would you like to set a default project? (y/n)${NC}" 198 | read -p "Select option: " set_default_project 199 | 200 | default_project="" 201 | if [[ "$set_default_project" = "y" || "$set_default_project" = "Y" ]]; then 202 | # Configure az devops to use the selected organization 203 | az devops configure --defaults organization=$org_url 204 | 205 | # List projects 206 | echo "Fetching projects from $org_name..." 207 | projects=$(az devops project list --query "value[].name" -o tsv) 208 | 209 | if [ $? -ne 0 ] || [ -z "$projects" ]; then 210 | echo -e "${YELLOW}No projects found or unable to list projects.${NC}" 211 | read -p "Enter a default project name (leave blank to skip): " default_project 212 | else 213 | # Display projects for selection 214 | echo -e "\nAvailable projects in $org_name:" 215 | OLDIFS=$IFS 216 | IFS=$'\n' 217 | # Create array in a shell-agnostic way 218 | projects_array=() 219 | while IFS= read -r line; do 220 | [ -n "$line" ] && projects_array+=("$line") 221 | done <<< "$projects" 222 | IFS=$OLDIFS 223 | 224 | # Check if array is empty 225 | if [ ${#projects_array[@]} -eq 0 ]; then 226 | echo -e "${YELLOW}Failed to parse projects list.${NC}" 227 | read -p "Enter a default project name (leave blank to skip): " default_project 228 | else 229 | # Display projects with explicit indexing 230 | for ((idx=0; idx<${#projects_array[@]}; idx++)); do 231 | echo "$((idx+1)) ${projects_array[$idx]}" 232 | done 233 | 234 | echo "$((${#projects_array[@]}+1)) Skip setting a default project" 235 | 236 | # Prompt for selection 237 | read -p "Select a default project (1-$((${#projects_array[@]}+1))): " project_selection 238 | 239 | if [[ "$project_selection" =~ ^[0-9]+$ ]] && [ "$project_selection" -ge 1 ] && [ "$project_selection" -lt "$((${#projects_array[@]}+1))" ]; then 240 | default_project=${projects_array[$((project_selection-1))]} 241 | echo -e "${GREEN}Using default project: $default_project${NC}" 242 | else 243 | echo "No default project selected." 244 | fi 245 | fi 246 | fi 247 | fi 248 | 249 | # Create .env file 250 | echo -e "\n${YELLOW}Step 5: Creating .env file...${NC}" 251 | 252 | cat > .env << EOF 253 | # Azure DevOps MCP Server - Environment Variables 254 | 255 | # Azure DevOps Organization Name (selected from your available organizations) 256 | AZURE_DEVOPS_ORG=$org_name 257 | 258 | # Azure DevOps Organization URL (required) 259 | AZURE_DEVOPS_ORG_URL=$org_url 260 | 261 | 262 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 263 | EOF 264 | 265 | # Add default project if specified 266 | if [ ! -z "$default_project" ]; then 267 | cat >> .env << EOF 268 | 269 | # Default Project to use when not specified 270 | AZURE_DEVOPS_DEFAULT_PROJECT=$default_project 271 | EOF 272 | else 273 | cat >> .env << EOF 274 | 275 | # Default Project to use when not specified (optional) 276 | # AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project 277 | EOF 278 | fi 279 | 280 | # Add remaining configuration 281 | cat >> .env << EOF 282 | 283 | # API Version to use (optional, defaults to latest) 284 | # AZURE_DEVOPS_API_VERSION=6.0 285 | 286 | # Server Configuration 287 | PORT=3000 288 | HOST=localhost 289 | 290 | # Logging Level (debug, info, warn, error) 291 | LOG_LEVEL=info 292 | EOF 293 | 294 | echo -e "\n${GREEN}Environment setup completed successfully!${NC}" 295 | echo "Your .env file has been created with the following configuration:" 296 | echo "- Organization: $org_name" 297 | echo "- Organization URL: $org_url" 298 | if [ ! -z "$default_project" ]; then 299 | echo "- Default Project: $default_project" 300 | fi 301 | echo "- PAT: Created with expanded scopes for full integration" 302 | echo 303 | echo "You can now run your Azure DevOps MCP Server with:" 304 | echo " npm run dev" 305 | echo 306 | echo "You can also run integration tests with:" 307 | echo " npm run test:integration" 308 | 309 | # At the end of the script, ensure colors are reset 310 | echo -e "${NC}" ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { IGitApi } from 'azure-devops-node-api/GitApi'; 3 | import { 4 | GitVersionType, 5 | VersionControlRecursionType, 6 | GitItem, 7 | GitObjectType, 8 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 9 | import { minimatch } from 'minimatch'; 10 | import { AzureDevOpsError } from '../../../shared/errors'; 11 | import { 12 | GetAllRepositoriesTreeOptions, 13 | AllRepositoriesTreeResponse, 14 | RepositoryTreeResponse, 15 | RepositoryTreeItem, 16 | GitRepository, 17 | } from '../types'; 18 | 19 | /** 20 | * Get tree view of files/directories across multiple repositories 21 | * 22 | * @param connection The Azure DevOps WebApi connection 23 | * @param options Options for getting repository tree 24 | * @returns Tree structure for each repository 25 | */ 26 | export async function getAllRepositoriesTree( 27 | connection: WebApi, 28 | options: GetAllRepositoriesTreeOptions, 29 | ): Promise<AllRepositoriesTreeResponse> { 30 | try { 31 | const gitApi = await connection.getGitApi(); 32 | let repositories: GitRepository[] = []; 33 | 34 | // Get all repositories in the project 35 | repositories = await gitApi.getRepositories(options.projectId); 36 | 37 | // Filter repositories by name pattern if specified 38 | if (options.repositoryPattern) { 39 | repositories = repositories.filter((repo) => 40 | minimatch(repo.name || '', options.repositoryPattern || '*'), 41 | ); 42 | } 43 | 44 | // Initialize results array 45 | const results: RepositoryTreeResponse[] = []; 46 | 47 | // Process each repository 48 | for (const repo of repositories) { 49 | try { 50 | // Get default branch ref 51 | const defaultBranch = repo.defaultBranch; 52 | if (!defaultBranch) { 53 | // Skip repositories with no default branch 54 | results.push({ 55 | name: repo.name || 'Unknown', 56 | tree: [], 57 | stats: { directories: 0, files: 0 }, 58 | error: 'No default branch found', 59 | }); 60 | continue; 61 | } 62 | 63 | // Clean the branch name (remove refs/heads/ prefix) 64 | const branchRef = defaultBranch.replace('refs/heads/', ''); 65 | 66 | // Initialize tree items array and counters 67 | const treeItems: RepositoryTreeItem[] = []; 68 | const stats = { directories: 0, files: 0 }; 69 | 70 | // Determine the recursion level and processing approach 71 | const depth = options.depth !== undefined ? options.depth : 0; // Default to 0 (max depth) 72 | 73 | if (depth === 0) { 74 | // For max depth (0), use server-side recursion for better performance 75 | const allItems = await gitApi.getItems( 76 | repo.id || '', 77 | options.projectId, 78 | '/', 79 | VersionControlRecursionType.Full, // Use full recursion 80 | true, 81 | false, 82 | false, 83 | false, 84 | { 85 | version: branchRef, 86 | versionType: GitVersionType.Branch, 87 | }, 88 | ); 89 | 90 | // Filter out the root item itself and bad items 91 | const itemsToProcess = allItems.filter( 92 | (item) => 93 | item.path !== '/' && item.gitObjectType !== GitObjectType.Bad, 94 | ); 95 | 96 | // Process all items at once (they're already retrieved recursively) 97 | processItemsNonRecursive( 98 | itemsToProcess, 99 | treeItems, 100 | stats, 101 | options.pattern, 102 | ); 103 | } else { 104 | // For limited depth, use the regular recursive approach 105 | // Get items at the root level 106 | const rootItems = await gitApi.getItems( 107 | repo.id || '', 108 | options.projectId, 109 | '/', 110 | VersionControlRecursionType.OneLevel, 111 | true, 112 | false, 113 | false, 114 | false, 115 | { 116 | version: branchRef, 117 | versionType: GitVersionType.Branch, 118 | }, 119 | ); 120 | 121 | // Filter out the root item itself and bad items 122 | const itemsToProcess = rootItems.filter( 123 | (item) => 124 | item.path !== '/' && item.gitObjectType !== GitObjectType.Bad, 125 | ); 126 | 127 | // Process the root items and their children (up to specified depth) 128 | await processItems( 129 | gitApi, 130 | repo.id || '', 131 | options.projectId, 132 | itemsToProcess, 133 | branchRef, 134 | treeItems, 135 | stats, 136 | 1, 137 | depth, 138 | options.pattern, 139 | ); 140 | } 141 | 142 | // Add repository tree to results 143 | results.push({ 144 | name: repo.name || 'Unknown', 145 | tree: treeItems, 146 | stats, 147 | }); 148 | } catch (repoError) { 149 | // Handle errors for individual repositories 150 | results.push({ 151 | name: repo.name || 'Unknown', 152 | tree: [], 153 | stats: { directories: 0, files: 0 }, 154 | error: `Error processing repository: ${repoError instanceof Error ? repoError.message : String(repoError)}`, 155 | }); 156 | } 157 | } 158 | 159 | return { repositories: results }; 160 | } catch (error) { 161 | if (error instanceof AzureDevOpsError) { 162 | throw error; 163 | } 164 | throw new Error( 165 | `Failed to get repository tree: ${error instanceof Error ? error.message : String(error)}`, 166 | ); 167 | } 168 | } 169 | 170 | /** 171 | * Process items non-recursively when they're already retrieved with VersionControlRecursionType.Full 172 | */ 173 | function processItemsNonRecursive( 174 | items: GitItem[], 175 | result: RepositoryTreeItem[], 176 | stats: { directories: number; files: number }, 177 | pattern?: string, 178 | ): void { 179 | // Sort items (folders first, then by path) 180 | const sortedItems = [...items].sort((a, b) => { 181 | if (a.isFolder === b.isFolder) { 182 | return (a.path || '').localeCompare(b.path || ''); 183 | } 184 | return a.isFolder ? -1 : 1; 185 | }); 186 | 187 | for (const item of sortedItems) { 188 | const name = item.path?.split('/').pop() || ''; 189 | const path = item.path || ''; 190 | const isFolder = !!item.isFolder; 191 | 192 | // Skip the root folder 193 | if (path === '/') { 194 | continue; 195 | } 196 | 197 | // Calculate level from path segments 198 | // Remove leading '/' then count segments 199 | // For paths like: 200 | // /README.md -> ["README.md"] -> length 1 -> level 1 201 | // /src/index.ts -> ["src", "index.ts"] -> length 2 -> level 2 202 | // /src/utils/helper.ts -> ["src", "utils", "helper.ts"] -> length 3 -> level 3 203 | const pathSegments = path.replace(/^\//, '').split('/'); 204 | const level = pathSegments.length; 205 | 206 | // Filter files based on pattern (if specified) 207 | if (!isFolder && pattern && !minimatch(name, pattern)) { 208 | continue; 209 | } 210 | 211 | // Add item to results 212 | result.push({ 213 | name, 214 | path, 215 | isFolder, 216 | level, 217 | }); 218 | 219 | // Update counters 220 | if (isFolder) { 221 | stats.directories++; 222 | } else { 223 | stats.files++; 224 | } 225 | } 226 | } 227 | 228 | /** 229 | * Process items recursively up to the specified depth 230 | */ 231 | async function processItems( 232 | gitApi: IGitApi, 233 | repoId: string, 234 | projectId: string, 235 | items: GitItem[], 236 | branchRef: string, 237 | result: RepositoryTreeItem[], 238 | stats: { directories: number; files: number }, 239 | currentDepth: number, 240 | maxDepth: number, 241 | pattern?: string, 242 | ): Promise<void> { 243 | // Sort items (directories first, then files) 244 | const sortedItems = [...items].sort((a, b) => { 245 | if (a.isFolder === b.isFolder) { 246 | return (a.path || '').localeCompare(b.path || ''); 247 | } 248 | return a.isFolder ? -1 : 1; 249 | }); 250 | 251 | for (const item of sortedItems) { 252 | const name = item.path?.split('/').pop() || ''; 253 | const path = item.path || ''; 254 | const isFolder = !!item.isFolder; 255 | 256 | // Filter files based on pattern (if specified) 257 | if (!isFolder && pattern && !minimatch(name, pattern)) { 258 | continue; 259 | } 260 | 261 | // Add item to results 262 | result.push({ 263 | name, 264 | path, 265 | isFolder, 266 | level: currentDepth, 267 | }); 268 | 269 | // Update counters 270 | if (isFolder) { 271 | stats.directories++; 272 | } else { 273 | stats.files++; 274 | } 275 | 276 | // Recursively process folders if not yet at max depth 277 | if (isFolder && currentDepth < maxDepth) { 278 | try { 279 | const childItems = await gitApi.getItems( 280 | repoId, 281 | projectId, 282 | path, 283 | VersionControlRecursionType.OneLevel, 284 | true, 285 | false, 286 | false, 287 | false, 288 | { 289 | version: branchRef, 290 | versionType: GitVersionType.Branch, 291 | }, 292 | ); 293 | 294 | // Filter out the parent folder itself and bad items 295 | const itemsToProcess = childItems.filter( 296 | (child: GitItem) => 297 | child.path !== path && child.gitObjectType !== GitObjectType.Bad, 298 | ); 299 | 300 | // Process child items 301 | await processItems( 302 | gitApi, 303 | repoId, 304 | projectId, 305 | itemsToProcess, 306 | branchRef, 307 | result, 308 | stats, 309 | currentDepth + 1, 310 | maxDepth, 311 | pattern, 312 | ); 313 | } catch (error) { 314 | // Ignore errors in child items and continue with siblings 315 | console.error(`Error processing folder ${path}: ${error}`); 316 | } 317 | } 318 | } 319 | } 320 | 321 | /** 322 | * Convert the tree items to a formatted ASCII string representation 323 | * 324 | * @param repoName Repository name 325 | * @param items Tree items 326 | * @param stats Statistics about files and directories 327 | * @returns Formatted ASCII string 328 | */ 329 | export function formatRepositoryTree( 330 | repoName: string, 331 | items: RepositoryTreeItem[], 332 | stats: { directories: number; files: number }, 333 | error?: string, 334 | ): string { 335 | let output = `${repoName}/\n`; 336 | 337 | if (error) { 338 | output += ` (${error})\n`; 339 | } else if (items.length === 0) { 340 | output += ' (Repository is empty or default branch not found)\n'; 341 | } else { 342 | // Sort items by path to ensure proper sequence 343 | const sortedItems = [...items].sort((a, b) => { 344 | // Sort by level first 345 | if (a.level !== b.level) { 346 | return a.level - b.level; 347 | } 348 | // Then folders before files 349 | if (a.isFolder !== b.isFolder) { 350 | return a.isFolder ? -1 : 1; 351 | } 352 | // Then alphabetically 353 | return a.path.localeCompare(b.path); 354 | }); 355 | 356 | // Create a structured tree representation 357 | const tree = createTreeStructure(sortedItems); 358 | 359 | // Format the tree starting from the root 360 | output += formatTree(tree, ' '); 361 | } 362 | 363 | // Add summary line 364 | output += `${stats.directories} directories, ${stats.files} files\n`; 365 | 366 | return output; 367 | } 368 | 369 | /** 370 | * Create a structured tree from the flat list of items 371 | */ 372 | function createTreeStructure(items: RepositoryTreeItem[]): TreeNode { 373 | const root: TreeNode = { 374 | name: '', 375 | path: '', 376 | isFolder: true, 377 | children: [], 378 | }; 379 | 380 | // Map to track all nodes by path 381 | const nodeMap: Record<string, TreeNode> = { '': root }; 382 | 383 | // First create all nodes 384 | for (const item of items) { 385 | nodeMap[item.path] = { 386 | name: item.name, 387 | path: item.path, 388 | isFolder: item.isFolder, 389 | children: [], 390 | }; 391 | } 392 | 393 | // Then build the hierarchy 394 | for (const item of items) { 395 | if (item.path === '/') continue; 396 | 397 | const node = nodeMap[item.path]; 398 | const lastSlashIndex = item.path.lastIndexOf('/'); 399 | 400 | // For root level items, the parent path is empty 401 | const parentPath = 402 | lastSlashIndex <= 0 ? '' : item.path.substring(0, lastSlashIndex); 403 | 404 | // Get parent node (defaults to root if parent not found) 405 | const parent = nodeMap[parentPath] || root; 406 | 407 | // Add this node as a child of its parent 408 | parent.children.push(node); 409 | } 410 | 411 | return root; 412 | } 413 | 414 | /** 415 | * Format a tree structure into an ASCII tree representation 416 | */ 417 | function formatTree(node: TreeNode, indent: string): string { 418 | if (!node.children.length) return ''; 419 | 420 | let output = ''; 421 | 422 | // Sort the children: folders first, then alphabetically 423 | const children = [...node.children].sort((a, b) => { 424 | if (a.isFolder !== b.isFolder) { 425 | return a.isFolder ? -1 : 1; 426 | } 427 | return a.name.localeCompare(b.name); 428 | }); 429 | 430 | // Format each child node 431 | for (let i = 0; i < children.length; i++) { 432 | const child = children[i]; 433 | const isLast = i === children.length - 1; 434 | const connector = isLast ? '`-- ' : '|-- '; 435 | const childIndent = isLast ? ' ' : '| '; 436 | 437 | // Add the node itself 438 | const suffix = child.isFolder ? '/' : ''; 439 | output += `${indent}${connector}${child.name}${suffix}\n`; 440 | 441 | // Recursively add its children 442 | if (child.children.length > 0) { 443 | output += formatTree(child, indent + childIndent); 444 | } 445 | } 446 | 447 | return output; 448 | } 449 | 450 | /** 451 | * Tree node interface for hierarchical representation 452 | */ 453 | interface TreeNode { 454 | name: string; 455 | path: string; 456 | isFolder: boolean; 457 | children: TreeNode[]; 458 | } 459 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/get-pull-request-comments/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { getPullRequestComments } from './feature'; 3 | import { GitPullRequestCommentThread } from 'azure-devops-node-api/interfaces/GitInterfaces'; 4 | 5 | describe('getPullRequestComments', () => { 6 | afterEach(() => { 7 | jest.resetAllMocks(); 8 | }); 9 | 10 | test('should return pull request comment threads with file path and line number', async () => { 11 | // Mock data for a comment thread 12 | const mockCommentThreads: GitPullRequestCommentThread[] = [ 13 | { 14 | id: 1, 15 | status: 1, // Active 16 | threadContext: { 17 | filePath: '/src/app.ts', 18 | rightFileStart: { 19 | line: 10, 20 | offset: 5, 21 | }, 22 | rightFileEnd: { 23 | line: 10, 24 | offset: 15, 25 | }, 26 | }, 27 | comments: [ 28 | { 29 | id: 100, 30 | content: 'This code needs refactoring', 31 | commentType: 1, // CodeChange 32 | author: { 33 | displayName: 'Test User', 34 | id: 'test-user-id', 35 | }, 36 | publishedDate: new Date(), 37 | }, 38 | { 39 | id: 101, 40 | parentCommentId: 100, 41 | content: 'I agree, will update', 42 | commentType: 1, // CodeChange 43 | author: { 44 | displayName: 'Another User', 45 | id: 'another-user-id', 46 | }, 47 | publishedDate: new Date(), 48 | }, 49 | ], 50 | }, 51 | ]; 52 | 53 | // Setup mock connection 54 | const mockGitApi = { 55 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads), 56 | getPullRequestThread: jest.fn(), 57 | }; 58 | 59 | const mockConnection: any = { 60 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 61 | }; 62 | 63 | // Call the function with test parameters 64 | const projectId = 'test-project'; 65 | const repositoryId = 'test-repo'; 66 | const pullRequestId = 123; 67 | const options = { 68 | projectId, 69 | repositoryId, 70 | pullRequestId, 71 | }; 72 | 73 | const result = await getPullRequestComments( 74 | mockConnection as WebApi, 75 | projectId, 76 | repositoryId, 77 | pullRequestId, 78 | options, 79 | ); 80 | 81 | // Verify results 82 | expect(result).toHaveLength(1); 83 | expect(result[0].comments).toHaveLength(2); 84 | 85 | // Verify file path and line number are added to each comment 86 | result[0].comments?.forEach((comment) => { 87 | expect(comment).toHaveProperty('filePath', '/src/app.ts'); 88 | expect(comment).toHaveProperty('rightFileStart', { line: 10, offset: 5 }); 89 | expect(comment).toHaveProperty('rightFileEnd', { line: 10, offset: 15 }); 90 | expect(comment).toHaveProperty('leftFileStart', undefined); 91 | expect(comment).toHaveProperty('leftFileEnd', undefined); 92 | }); 93 | 94 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 95 | expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1); 96 | expect(mockGitApi.getThreads).toHaveBeenCalledWith( 97 | repositoryId, 98 | pullRequestId, 99 | projectId, 100 | undefined, 101 | undefined, 102 | ); 103 | expect(mockGitApi.getPullRequestThread).not.toHaveBeenCalled(); 104 | }); 105 | 106 | test('should handle comments without thread context', async () => { 107 | // Mock data for a comment thread without thread context 108 | const mockCommentThreads: GitPullRequestCommentThread[] = [ 109 | { 110 | id: 1, 111 | status: 1, // Active 112 | comments: [ 113 | { 114 | id: 100, 115 | content: 'General comment', 116 | commentType: 1, 117 | author: { 118 | displayName: 'Test User', 119 | id: 'test-user-id', 120 | }, 121 | publishedDate: new Date(), 122 | }, 123 | ], 124 | }, 125 | ]; 126 | 127 | // Setup mock connection 128 | const mockGitApi = { 129 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads), 130 | getPullRequestThread: jest.fn(), 131 | }; 132 | 133 | const mockConnection: any = { 134 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 135 | }; 136 | 137 | const result = await getPullRequestComments( 138 | mockConnection as WebApi, 139 | 'test-project', 140 | 'test-repo', 141 | 123, 142 | { 143 | projectId: 'test-project', 144 | repositoryId: 'test-repo', 145 | pullRequestId: 123, 146 | }, 147 | ); 148 | 149 | // Verify results 150 | expect(result).toHaveLength(1); 151 | expect(result[0].comments).toHaveLength(1); 152 | expect(result[0].status).toBe('active'); 153 | 154 | // Verify file path and line number are null for comments without thread context 155 | const comment = result[0].comments![0]; 156 | expect(comment).toHaveProperty('filePath', undefined); 157 | expect(comment).toHaveProperty('rightFileStart', undefined); 158 | expect(comment).toHaveProperty('rightFileEnd', undefined); 159 | expect(comment).toHaveProperty('leftFileStart', undefined); 160 | expect(comment).toHaveProperty('leftFileEnd', undefined); 161 | expect(comment).toHaveProperty('commentType', 'text'); 162 | }); 163 | 164 | test('should use leftFileStart when rightFileStart is not available', async () => { 165 | // Mock data for a comment thread with only leftFileStart 166 | const mockCommentThreads: GitPullRequestCommentThread[] = [ 167 | { 168 | id: 1, 169 | status: 1, 170 | threadContext: { 171 | filePath: '/src/app.ts', 172 | leftFileStart: { 173 | line: 5, 174 | offset: 1, 175 | }, 176 | }, 177 | comments: [ 178 | { 179 | id: 100, 180 | content: 'Comment on deleted line', 181 | commentType: 1, 182 | author: { 183 | displayName: 'Test User', 184 | id: 'test-user-id', 185 | }, 186 | publishedDate: new Date(), 187 | }, 188 | ], 189 | }, 190 | ]; 191 | 192 | // Setup mock connection 193 | const mockGitApi = { 194 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads), 195 | getPullRequestThread: jest.fn(), 196 | }; 197 | 198 | const mockConnection: any = { 199 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 200 | }; 201 | 202 | const result = await getPullRequestComments( 203 | mockConnection as WebApi, 204 | 'test-project', 205 | 'test-repo', 206 | 123, 207 | { 208 | projectId: 'test-project', 209 | repositoryId: 'test-repo', 210 | pullRequestId: 123, 211 | }, 212 | ); 213 | 214 | // Verify results 215 | expect(result).toHaveLength(1); 216 | expect(result[0].comments).toHaveLength(1); 217 | 218 | // Verify rightFileStart is undefined, leftFileStart is present 219 | const comment = result[0].comments![0]; 220 | expect(comment).toHaveProperty('filePath', '/src/app.ts'); 221 | expect(comment).toHaveProperty('leftFileStart', { line: 5, offset: 1 }); 222 | expect(comment).toHaveProperty('rightFileStart', undefined); 223 | expect(comment).toHaveProperty('leftFileEnd', undefined); 224 | expect(comment).toHaveProperty('rightFileEnd', undefined); 225 | }); 226 | 227 | test('should return a specific comment thread when threadId is provided', async () => { 228 | // Mock data for a specific comment thread 229 | const threadId = 42; 230 | const mockCommentThread: GitPullRequestCommentThread = { 231 | id: threadId, 232 | status: 1, // Active 233 | threadContext: { 234 | filePath: '/src/utils.ts', 235 | rightFileStart: { 236 | line: 15, 237 | offset: 1, 238 | }, 239 | }, 240 | comments: [ 241 | { 242 | id: 100, 243 | content: 'Specific comment', 244 | commentType: 1, // CodeChange 245 | author: { 246 | displayName: 'Test User', 247 | id: 'test-user-id', 248 | }, 249 | publishedDate: new Date(), 250 | }, 251 | ], 252 | }; 253 | 254 | // Setup mock connection 255 | const mockGitApi = { 256 | getThreads: jest.fn(), 257 | getPullRequestThread: jest.fn().mockResolvedValue(mockCommentThread), 258 | }; 259 | 260 | const mockConnection: any = { 261 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 262 | }; 263 | 264 | // Call the function with test parameters 265 | const projectId = 'test-project'; 266 | const repositoryId = 'test-repo'; 267 | const pullRequestId = 123; 268 | const options = { 269 | projectId, 270 | repositoryId, 271 | pullRequestId, 272 | threadId, 273 | }; 274 | 275 | const result = await getPullRequestComments( 276 | mockConnection as WebApi, 277 | projectId, 278 | repositoryId, 279 | pullRequestId, 280 | options, 281 | ); 282 | 283 | // Verify results 284 | expect(result).toHaveLength(1); 285 | expect(result[0].id).toBe(threadId); 286 | expect(result[0].comments).toHaveLength(1); 287 | 288 | // Verify file path and line number are added 289 | const comment = result[0].comments![0]; 290 | expect(comment).toHaveProperty('filePath', '/src/utils.ts'); 291 | expect(comment).toHaveProperty('rightFileStart', { line: 15, offset: 1 }); 292 | expect(comment).toHaveProperty('leftFileStart', undefined); 293 | expect(comment).toHaveProperty('leftFileEnd', undefined); 294 | expect(comment).toHaveProperty('rightFileEnd', undefined); 295 | 296 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 297 | expect(mockGitApi.getPullRequestThread).toHaveBeenCalledTimes(1); 298 | expect(mockGitApi.getPullRequestThread).toHaveBeenCalledWith( 299 | repositoryId, 300 | pullRequestId, 301 | threadId, 302 | projectId, 303 | ); 304 | expect(mockGitApi.getThreads).not.toHaveBeenCalled(); 305 | }); 306 | 307 | test('should handle pagination when top parameter is provided', async () => { 308 | // Mock data for multiple comment threads 309 | const mockCommentThreads: GitPullRequestCommentThread[] = [ 310 | { 311 | id: 1, 312 | status: 1, 313 | threadContext: { 314 | filePath: '/src/file1.ts', 315 | rightFileStart: { line: 1, offset: 1 }, 316 | }, 317 | comments: [{ id: 100, content: 'Comment 1' }], 318 | }, 319 | { 320 | id: 2, 321 | status: 1, 322 | threadContext: { 323 | filePath: '/src/file2.ts', 324 | rightFileStart: { line: 2, offset: 1 }, 325 | }, 326 | comments: [{ id: 101, content: 'Comment 2' }], 327 | }, 328 | { 329 | id: 3, 330 | status: 1, 331 | threadContext: { 332 | filePath: '/src/file3.ts', 333 | rightFileStart: { line: 3, offset: 1 }, 334 | }, 335 | comments: [{ id: 102, content: 'Comment 3' }], 336 | }, 337 | ]; 338 | 339 | // Setup mock connection 340 | const mockGitApi = { 341 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads), 342 | getPullRequestThread: jest.fn(), 343 | }; 344 | 345 | const mockConnection: any = { 346 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 347 | }; 348 | 349 | // Call the function with test parameters and top=2 350 | const projectId = 'test-project'; 351 | const repositoryId = 'test-repo'; 352 | const pullRequestId = 123; 353 | const options = { 354 | projectId, 355 | repositoryId, 356 | pullRequestId, 357 | top: 2, 358 | }; 359 | 360 | const result = await getPullRequestComments( 361 | mockConnection as WebApi, 362 | projectId, 363 | repositoryId, 364 | pullRequestId, 365 | options, 366 | ); 367 | 368 | // Verify results (should only include first 2 threads) 369 | expect(result).toHaveLength(2); 370 | expect(result).toEqual( 371 | mockCommentThreads.slice(0, 2).map((thread) => ({ 372 | ...thread, 373 | status: 'active', // Transform enum to string 374 | comments: thread.comments?.map((comment) => ({ 375 | ...comment, 376 | commentType: undefined, // Will be undefined since mock doesn't have commentType 377 | filePath: thread.threadContext?.filePath, 378 | rightFileStart: thread.threadContext?.rightFileStart ?? undefined, 379 | rightFileEnd: thread.threadContext?.rightFileEnd ?? undefined, 380 | leftFileStart: thread.threadContext?.leftFileStart ?? undefined, 381 | leftFileEnd: thread.threadContext?.leftFileEnd ?? undefined, 382 | })), 383 | })), 384 | ); 385 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 386 | expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1); 387 | expect(result[0].comments![0]).toHaveProperty('rightFileStart', { 388 | line: 1, 389 | offset: 1, 390 | }); 391 | expect(result[1].comments![0]).toHaveProperty('rightFileStart', { 392 | line: 2, 393 | offset: 1, 394 | }); 395 | }); 396 | 397 | test('should handle error when API call fails', async () => { 398 | // Setup mock connection with error 399 | const errorMessage = 'API error'; 400 | const mockGitApi = { 401 | getThreads: jest.fn().mockRejectedValue(new Error(errorMessage)), 402 | }; 403 | 404 | const mockConnection: any = { 405 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 406 | }; 407 | 408 | // Call the function with test parameters 409 | const projectId = 'test-project'; 410 | const repositoryId = 'test-repo'; 411 | const pullRequestId = 123; 412 | const options = { 413 | projectId, 414 | repositoryId, 415 | pullRequestId, 416 | }; 417 | 418 | // Verify error handling 419 | await expect( 420 | getPullRequestComments( 421 | mockConnection as WebApi, 422 | projectId, 423 | repositoryId, 424 | pullRequestId, 425 | options, 426 | ), 427 | ).rejects.toThrow(`Failed to get pull request comments: ${errorMessage}`); 428 | }); 429 | }); 430 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { 3 | GitObjectType, 4 | VersionControlRecursionType, 5 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 6 | import { getAllRepositoriesTree, formatRepositoryTree } from './feature'; 7 | import { RepositoryTreeItem } from '../types'; 8 | 9 | // Mock the Azure DevOps API 10 | jest.mock('azure-devops-node-api'); 11 | 12 | describe('getAllRepositoriesTree', () => { 13 | // Sample repositories 14 | const mockRepos = [ 15 | { 16 | id: 'repo1-id', 17 | name: 'repo1', 18 | defaultBranch: 'refs/heads/main', 19 | }, 20 | { 21 | id: 'repo2-id', 22 | name: 'repo2', 23 | defaultBranch: 'refs/heads/master', 24 | }, 25 | { 26 | id: 'repo3-id', 27 | name: 'repo3-api', 28 | defaultBranch: null, // No default branch 29 | }, 30 | ]; 31 | 32 | // Sample files/folders for repo1 at root level 33 | const mockRepo1RootItems = [ 34 | { 35 | path: '/', 36 | gitObjectType: GitObjectType.Tree, 37 | }, 38 | { 39 | path: '/README.md', 40 | isFolder: false, 41 | gitObjectType: GitObjectType.Blob, 42 | }, 43 | { 44 | path: '/src', 45 | isFolder: true, 46 | gitObjectType: GitObjectType.Tree, 47 | }, 48 | { 49 | path: '/package.json', 50 | isFolder: false, 51 | gitObjectType: GitObjectType.Blob, 52 | }, 53 | ]; 54 | 55 | // Sample files/folders for repo1 - src folder 56 | const mockRepo1SrcItems = [ 57 | { 58 | path: '/src', 59 | isFolder: true, 60 | gitObjectType: GitObjectType.Tree, 61 | }, 62 | { 63 | path: '/src/index.ts', 64 | isFolder: false, 65 | gitObjectType: GitObjectType.Blob, 66 | }, 67 | { 68 | path: '/src/utils', 69 | isFolder: true, 70 | gitObjectType: GitObjectType.Tree, 71 | }, 72 | ]; 73 | 74 | // Sample files/folders for repo1 with unlimited depth (what server would return for Full recursion) 75 | const mockRepo1FullRecursionItems = [ 76 | { 77 | path: '/', 78 | gitObjectType: GitObjectType.Tree, 79 | }, 80 | { 81 | path: '/README.md', 82 | isFolder: false, 83 | gitObjectType: GitObjectType.Blob, 84 | }, 85 | { 86 | path: '/src', 87 | isFolder: true, 88 | gitObjectType: GitObjectType.Tree, 89 | }, 90 | { 91 | path: '/package.json', 92 | isFolder: false, 93 | gitObjectType: GitObjectType.Blob, 94 | }, 95 | { 96 | path: '/src/index.ts', 97 | isFolder: false, 98 | gitObjectType: GitObjectType.Blob, 99 | }, 100 | { 101 | path: '/src/utils', 102 | isFolder: true, 103 | gitObjectType: GitObjectType.Tree, 104 | }, 105 | { 106 | path: '/src/utils/helper.ts', 107 | isFolder: false, 108 | gitObjectType: GitObjectType.Blob, 109 | }, 110 | { 111 | path: '/src/utils/constants.ts', 112 | isFolder: false, 113 | gitObjectType: GitObjectType.Blob, 114 | }, 115 | ]; 116 | 117 | // Sample files/folders for repo2 118 | const mockRepo2RootItems = [ 119 | { 120 | path: '/', 121 | gitObjectType: GitObjectType.Tree, 122 | }, 123 | { 124 | path: '/README.md', 125 | isFolder: false, 126 | gitObjectType: GitObjectType.Blob, 127 | }, 128 | { 129 | path: '/data.json', 130 | isFolder: false, 131 | gitObjectType: GitObjectType.Blob, 132 | }, 133 | ]; 134 | 135 | let mockConnection: jest.Mocked<WebApi>; 136 | let mockGitApi: any; 137 | 138 | beforeEach(() => { 139 | // Clear mocks 140 | jest.clearAllMocks(); 141 | 142 | // Create mock GitApi 143 | mockGitApi = { 144 | getRepositories: jest.fn().mockResolvedValue(mockRepos), 145 | getItems: jest 146 | .fn() 147 | .mockImplementation((repoId, _projectId, path, recursionLevel) => { 148 | if (repoId === 'repo1-id') { 149 | if (recursionLevel === VersionControlRecursionType.Full) { 150 | return Promise.resolve(mockRepo1FullRecursionItems); 151 | } else if (path === '/') { 152 | return Promise.resolve(mockRepo1RootItems); 153 | } else if (path === '/src') { 154 | return Promise.resolve(mockRepo1SrcItems); 155 | } 156 | } else if (repoId === 'repo2-id') { 157 | if (recursionLevel === VersionControlRecursionType.Full) { 158 | return Promise.resolve(mockRepo2RootItems); 159 | } else if (path === '/') { 160 | return Promise.resolve(mockRepo2RootItems); 161 | } 162 | } 163 | return Promise.resolve([]); 164 | }), 165 | }; 166 | 167 | // Create mock connection 168 | mockConnection = { 169 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 170 | } as unknown as jest.Mocked<WebApi>; 171 | }); 172 | 173 | it('should return tree structures for multiple repositories with limited depth', async () => { 174 | // Arrange 175 | const options = { 176 | organizationId: 'testOrg', 177 | projectId: 'testProject', 178 | depth: 2, // Limited depth 179 | }; 180 | 181 | // Act 182 | const result = await getAllRepositoriesTree(mockConnection, options); 183 | 184 | // Assert 185 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); 186 | expect(result.repositories.length).toBe(3); 187 | 188 | // Verify repo1 tree 189 | const repo1 = result.repositories.find((r) => r.name === 'repo1'); 190 | expect(repo1).toBeDefined(); 191 | expect(repo1?.tree.length).toBeGreaterThan(0); 192 | expect(repo1?.stats.directories).toBeGreaterThan(0); 193 | expect(repo1?.stats.files).toBeGreaterThan(0); 194 | 195 | // Verify repo2 tree 196 | const repo2 = result.repositories.find((r) => r.name === 'repo2'); 197 | expect(repo2).toBeDefined(); 198 | expect(repo2?.tree.length).toBeGreaterThan(0); 199 | 200 | // Verify repo3 has error (no default branch) 201 | const repo3 = result.repositories.find((r) => r.name === 'repo3-api'); 202 | expect(repo3).toBeDefined(); 203 | expect(repo3?.error).toContain('No default branch found'); 204 | 205 | // Verify recursion level was set correctly 206 | expect(mockGitApi.getItems).toHaveBeenCalledWith( 207 | 'repo1-id', 208 | 'testProject', 209 | '/', 210 | VersionControlRecursionType.OneLevel, 211 | expect.anything(), 212 | expect.anything(), 213 | expect.anything(), 214 | expect.anything(), 215 | expect.anything(), 216 | ); 217 | }); 218 | 219 | it('should return tree structures with max depth using Full recursion', async () => { 220 | // Arrange 221 | const options = { 222 | organizationId: 'testOrg', 223 | projectId: 'testProject', 224 | depth: 0, // Max depth 225 | }; 226 | 227 | // Act 228 | const result = await getAllRepositoriesTree(mockConnection, options); 229 | 230 | // Assert 231 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); 232 | expect(result.repositories.length).toBe(3); 233 | 234 | // Verify repo1 tree 235 | const repo1 = result.repositories.find((r) => r.name === 'repo1'); 236 | expect(repo1).toBeDefined(); 237 | expect(repo1?.tree.length).toBeGreaterThan(0); 238 | // Should include all items, including nested ones 239 | expect(repo1?.tree.length).toBe(mockRepo1FullRecursionItems.length - 1); // -1 for root folder 240 | 241 | // Verify recursion level was set correctly 242 | expect(mockGitApi.getItems).toHaveBeenCalledWith( 243 | 'repo1-id', 244 | 'testProject', 245 | '/', 246 | VersionControlRecursionType.Full, 247 | expect.anything(), 248 | expect.anything(), 249 | expect.anything(), 250 | expect.anything(), 251 | expect.anything(), 252 | ); 253 | 254 | // Verify all levels are represented 255 | if (repo1) { 256 | const level1Items = repo1.tree.filter((item) => item.level === 1); 257 | const level2Items = repo1.tree.filter((item) => item.level === 2); 258 | const level3Items = repo1.tree.filter((item) => item.level === 3); 259 | 260 | // Verify we have items at level 1 261 | expect(level1Items.length).toBeGreaterThan(0); 262 | 263 | // Verify we have items at level 2 (src/something) 264 | expect(level2Items.length).toBeGreaterThan(0); 265 | 266 | // Check for level 3 items if they exist in our mock data 267 | if ( 268 | mockRepo1FullRecursionItems.some((item) => { 269 | const pathSegments = item.path.split('/').filter(Boolean); 270 | return pathSegments.length >= 3; 271 | }) 272 | ) { 273 | expect(level3Items.length).toBeGreaterThan(0); 274 | } 275 | } 276 | }); 277 | 278 | it('should filter repositories by pattern', async () => { 279 | // Arrange 280 | const options = { 281 | organizationId: 'testOrg', 282 | projectId: 'testProject', 283 | repositoryPattern: '*api*', 284 | depth: 1, 285 | }; 286 | 287 | // Act 288 | const result = await getAllRepositoriesTree(mockConnection, options); 289 | 290 | // Assert 291 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); 292 | expect(result.repositories.length).toBe(1); 293 | expect(result.repositories[0].name).toBe('repo3-api'); 294 | }); 295 | 296 | it('should format repository tree correctly', () => { 297 | // Arrange 298 | const treeItems: RepositoryTreeItem[] = [ 299 | { name: 'src', path: '/src', isFolder: true, level: 1 }, 300 | { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 }, 301 | { name: 'README.md', path: '/README.md', isFolder: false, level: 1 }, 302 | ]; 303 | const stats = { directories: 1, files: 2 }; 304 | 305 | // Act 306 | const formatted = formatRepositoryTree('test-repo', treeItems, stats); 307 | 308 | // Assert 309 | expect(formatted).toMatchSnapshot(); 310 | }); 311 | 312 | it('should format complex repository tree structures correctly', () => { 313 | // Arrange 314 | const treeItems: RepositoryTreeItem[] = [ 315 | // Root level files 316 | { name: 'README.md', path: '/README.md', isFolder: false, level: 1 }, 317 | { 318 | name: 'package.json', 319 | path: '/package.json', 320 | isFolder: false, 321 | level: 1, 322 | }, 323 | { name: '.gitignore', path: '/.gitignore', isFolder: false, level: 1 }, 324 | 325 | // Multiple folders at root level 326 | { name: 'src', path: '/src', isFolder: true, level: 1 }, 327 | { name: 'tests', path: '/tests', isFolder: true, level: 1 }, 328 | { name: 'docs', path: '/docs', isFolder: true, level: 1 }, 329 | 330 | // Nested src folder structure 331 | { name: 'components', path: '/src/components', isFolder: true, level: 2 }, 332 | { name: 'utils', path: '/src/utils', isFolder: true, level: 2 }, 333 | { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 }, 334 | 335 | // Deeply nested components 336 | { 337 | name: 'Button', 338 | path: '/src/components/Button', 339 | isFolder: true, 340 | level: 3, 341 | }, 342 | { name: 'Card', path: '/src/components/Card', isFolder: true, level: 3 }, 343 | { 344 | name: 'Button.tsx', 345 | path: '/src/components/Button/Button.tsx', 346 | isFolder: false, 347 | level: 4, 348 | }, 349 | { 350 | name: 'Button.styles.ts', 351 | path: '/src/components/Button/Button.styles.ts', 352 | isFolder: false, 353 | level: 4, 354 | }, 355 | { 356 | name: 'Button.test.tsx', 357 | path: '/src/components/Button/Button.test.tsx', 358 | isFolder: false, 359 | level: 4, 360 | }, 361 | { 362 | name: 'index.ts', 363 | path: '/src/components/Button/index.ts', 364 | isFolder: false, 365 | level: 4, 366 | }, 367 | { 368 | name: 'Card.tsx', 369 | path: '/src/components/Card/Card.tsx', 370 | isFolder: false, 371 | level: 4, 372 | }, 373 | 374 | // Utils with files 375 | { 376 | name: 'helpers.ts', 377 | path: '/src/utils/helpers.ts', 378 | isFolder: false, 379 | level: 3, 380 | }, 381 | { 382 | name: 'constants.ts', 383 | path: '/src/utils/constants.ts', 384 | isFolder: false, 385 | level: 3, 386 | }, 387 | 388 | // Empty folder 389 | { name: 'assets', path: '/src/assets', isFolder: true, level: 2 }, 390 | 391 | // Files with special characters 392 | { 393 | name: 'file-with-dashes.js', 394 | path: '/src/file-with-dashes.js', 395 | isFolder: false, 396 | level: 2, 397 | }, 398 | { 399 | name: 'file_with_underscores.js', 400 | path: '/src/file_with_underscores.js', 401 | isFolder: false, 402 | level: 2, 403 | }, 404 | 405 | // Folders in test directory 406 | { name: 'unit', path: '/tests/unit', isFolder: true, level: 2 }, 407 | { 408 | name: 'integration', 409 | path: '/tests/integration', 410 | isFolder: true, 411 | level: 2, 412 | }, 413 | 414 | // Files in test directories 415 | { name: 'setup.js', path: '/tests/setup.js', isFolder: false, level: 2 }, 416 | { 417 | name: 'example.test.js', 418 | path: '/tests/unit/example.test.js', 419 | isFolder: false, 420 | level: 3, 421 | }, 422 | 423 | // Files in docs 424 | { name: 'API.md', path: '/docs/API.md', isFolder: false, level: 2 }, 425 | { 426 | name: 'CONTRIBUTING.md', 427 | path: '/docs/CONTRIBUTING.md', 428 | isFolder: false, 429 | level: 2, 430 | }, 431 | ]; 432 | 433 | const stats = { directories: 10, files: 18 }; 434 | 435 | // Act 436 | const formatted = formatRepositoryTree('complex-repo', treeItems, stats); 437 | 438 | // Assert 439 | expect(formatted).toMatchSnapshot(); 440 | }); 441 | 442 | it('should handle repository errors gracefully', async () => { 443 | // Arrange 444 | mockGitApi.getItems = jest.fn().mockRejectedValue(new Error('API error')); 445 | 446 | const options = { 447 | organizationId: 'testOrg', 448 | projectId: 'testProject', 449 | depth: 1, 450 | }; 451 | 452 | // Act 453 | const result = await getAllRepositoriesTree(mockConnection, options); 454 | 455 | // Assert 456 | expect(result.repositories.length).toBe(3); 457 | const repo1 = result.repositories.find((r) => r.name === 'repo1'); 458 | expect(repo1?.error).toBeDefined(); 459 | }); 460 | }); 461 | ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { createWikiPage } from './feature'; 3 | import { CreateWikiPageSchema } from './schema'; 4 | import { getWikiPage } from '../get-wiki-page/feature'; 5 | import { getWikis } from '../get-wikis/feature'; 6 | import { 7 | getTestConnection, 8 | shouldSkipIntegrationTest, 9 | } from '@/shared/test/test-helpers'; 10 | import { getOrgNameFromUrl } from '@/utils/environment'; 11 | import { AzureDevOpsError } from '@/shared/errors/azure-devops-errors'; 12 | import { z } from 'zod'; 13 | 14 | // Ensure environment variables are set for testing 15 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = 16 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'default-project'; 17 | 18 | describe('createWikiPage Integration Tests', () => { 19 | let connection: WebApi | null = null; 20 | let projectName: string; 21 | let orgUrl: string; 22 | let organizationId: string; 23 | const testPagePath = '/IntegrationTestPage'; 24 | const testPagePathSub = '/IntegrationTestPage/SubPage'; 25 | const testPagePathDefault = '/DefaultPathPage'; 26 | const testPagePathComment = '/CommentTestPage'; 27 | 28 | beforeAll(async () => { 29 | // Mock the required environment variable for testing 30 | process.env.AZURE_DEVOPS_ORG_URL = 31 | process.env.AZURE_DEVOPS_ORG_URL || 'https://example.visualstudio.com'; 32 | 33 | // Get and validate required environment variables 34 | const envProjectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; 35 | if (!envProjectName) { 36 | throw new Error( 37 | 'AZURE_DEVOPS_DEFAULT_PROJECT environment variable is required', 38 | ); 39 | } 40 | projectName = envProjectName; 41 | 42 | const envOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; 43 | if (!envOrgUrl) { 44 | throw new Error('AZURE_DEVOPS_ORG_URL environment variable is required'); 45 | } 46 | orgUrl = envOrgUrl; 47 | organizationId = getOrgNameFromUrl(orgUrl); 48 | 49 | // Get a real connection using environment variables 50 | connection = await getTestConnection(); 51 | }); 52 | 53 | // Helper function to get a valid wiki ID 54 | async function getValidWikiId(): Promise<string | null> { 55 | if (!connection) return null; 56 | 57 | try { 58 | // Get available wikis 59 | const wikis = await getWikis(connection, { projectId: projectName }); 60 | 61 | // Skip if no wikis are available 62 | if (wikis.length === 0) { 63 | console.log('No wikis available in the project'); 64 | return null; 65 | } 66 | 67 | // Use the first available wiki 68 | const wiki = wikis[0]; 69 | if (!wiki.name) { 70 | console.log('Wiki name is undefined'); 71 | return null; 72 | } 73 | 74 | return wiki.name; 75 | } catch (error) { 76 | console.error('Error getting wikis:', error); 77 | return null; 78 | } 79 | } 80 | 81 | test('should create a new wiki page at the root', async () => { 82 | // Skip if no connection is available 83 | if (shouldSkipIntegrationTest()) { 84 | console.log('Skipping test due to missing connection'); 85 | return; 86 | } 87 | 88 | // This connection must be available if we didn't skip 89 | if (!connection) { 90 | throw new Error( 91 | 'Connection should be available when test is not skipped', 92 | ); 93 | } 94 | 95 | // Get a valid wiki ID 96 | const wikiId = await getValidWikiId(); 97 | if (!wikiId) { 98 | console.log('Skipping test: No valid wiki ID available'); 99 | return; 100 | } 101 | 102 | const params: z.infer<typeof CreateWikiPageSchema> = { 103 | organizationId, 104 | projectId: projectName, 105 | wikiId, 106 | pagePath: testPagePath, 107 | content: 'This is content for the integration test page (root).', 108 | }; 109 | 110 | try { 111 | // Create the wiki page 112 | const createdPage = await createWikiPage(params); 113 | 114 | // Verify the result 115 | expect(createdPage).toBeDefined(); 116 | expect(createdPage.path).toBe(testPagePath); 117 | expect(createdPage.content).toBe(params.content); 118 | 119 | // Verify by fetching the page 120 | const fetchedPage = await getWikiPage({ 121 | organizationId, 122 | projectId: projectName, 123 | wikiId, 124 | pagePath: testPagePath, 125 | }); 126 | 127 | expect(fetchedPage).toBeDefined(); 128 | expect(typeof fetchedPage).toBe('string'); 129 | expect(fetchedPage).toContain(params.content); 130 | } catch (error) { 131 | console.error('Error in test:', error); 132 | throw error; 133 | } 134 | }); 135 | 136 | test('should create a new wiki sub-page', async () => { 137 | // Skip if no connection is available 138 | if (shouldSkipIntegrationTest()) { 139 | console.log('Skipping test due to missing connection'); 140 | return; 141 | } 142 | 143 | // This connection must be available if we didn't skip 144 | if (!connection) { 145 | throw new Error( 146 | 'Connection should be available when test is not skipped', 147 | ); 148 | } 149 | 150 | // Get a valid wiki ID 151 | const wikiId = await getValidWikiId(); 152 | if (!wikiId) { 153 | console.log('Skipping test: No valid wiki ID available'); 154 | return; 155 | } 156 | 157 | // First, ensure the parent page exists 158 | const parentParams: z.infer<typeof CreateWikiPageSchema> = { 159 | organizationId, 160 | projectId: projectName, 161 | wikiId, 162 | pagePath: testPagePath, 163 | content: 'This is the parent page for the sub-page test.', 164 | }; 165 | 166 | try { 167 | // Create the parent page 168 | await createWikiPage(parentParams); 169 | 170 | // Now create the sub-page 171 | const subPageParams: z.infer<typeof CreateWikiPageSchema> = { 172 | organizationId, 173 | projectId: projectName, 174 | wikiId, 175 | pagePath: testPagePathSub, 176 | content: 'This is content for the integration test sub-page.', 177 | }; 178 | 179 | const createdSubPage = await createWikiPage(subPageParams); 180 | 181 | // Verify the result 182 | expect(createdSubPage).toBeDefined(); 183 | expect(createdSubPage.path).toBe(testPagePathSub); 184 | expect(createdSubPage.content).toBe(subPageParams.content); 185 | 186 | // Verify by fetching the sub-page 187 | const fetchedSubPage = await getWikiPage({ 188 | organizationId, 189 | projectId: projectName, 190 | wikiId, 191 | pagePath: testPagePathSub, 192 | }); 193 | 194 | expect(fetchedSubPage).toBeDefined(); 195 | expect(typeof fetchedSubPage).toBe('string'); 196 | expect(fetchedSubPage).toContain(subPageParams.content); 197 | } catch (error) { 198 | console.error('Error in test:', error); 199 | throw error; 200 | } 201 | }); 202 | 203 | test('should update an existing wiki page if path already exists', async () => { 204 | // Skip if no connection is available 205 | if (shouldSkipIntegrationTest()) { 206 | console.log('Skipping test due to missing connection'); 207 | return; 208 | } 209 | 210 | // This connection must be available if we didn't skip 211 | if (!connection) { 212 | throw new Error( 213 | 'Connection should be available when test is not skipped', 214 | ); 215 | } 216 | 217 | // Get a valid wiki ID 218 | const wikiId = await getValidWikiId(); 219 | if (!wikiId) { 220 | console.log('Skipping test: No valid wiki ID available'); 221 | return; 222 | } 223 | 224 | try { 225 | // First create a page with initial content 226 | const initialParams: z.infer<typeof CreateWikiPageSchema> = { 227 | organizationId, 228 | projectId: projectName, 229 | wikiId, 230 | pagePath: testPagePath, 231 | content: 'Initial content.', 232 | }; 233 | 234 | await createWikiPage(initialParams); 235 | 236 | // Now update the page with new content 237 | const updatedParams: z.infer<typeof CreateWikiPageSchema> = { 238 | ...initialParams, 239 | content: 'Updated content for the page.', 240 | }; 241 | 242 | const updatedPage = await createWikiPage(updatedParams); 243 | 244 | // Verify the result 245 | expect(updatedPage).toBeDefined(); 246 | expect(updatedPage.path).toBe(testPagePath); 247 | expect(updatedPage.content).toBe(updatedParams.content); 248 | 249 | // Verify by fetching the page 250 | const fetchedPage = await getWikiPage({ 251 | organizationId, 252 | projectId: projectName, 253 | wikiId, 254 | pagePath: testPagePath, 255 | }); 256 | 257 | expect(fetchedPage).toBeDefined(); 258 | expect(typeof fetchedPage).toBe('string'); 259 | expect(fetchedPage).toContain(updatedParams.content); 260 | } catch (error) { 261 | console.error('Error in test:', error); 262 | throw error; 263 | } 264 | }); 265 | 266 | test('should create a page with a default path if specified', async () => { 267 | // Skip if no connection is available 268 | if (shouldSkipIntegrationTest()) { 269 | console.log('Skipping test due to missing connection'); 270 | return; 271 | } 272 | 273 | // This connection must be available if we didn't skip 274 | if (!connection) { 275 | throw new Error( 276 | 'Connection should be available when test is not skipped', 277 | ); 278 | } 279 | 280 | // Get a valid wiki ID 281 | const wikiId = await getValidWikiId(); 282 | if (!wikiId) { 283 | console.log('Skipping test: No valid wiki ID available'); 284 | return; 285 | } 286 | 287 | try { 288 | const params: z.infer<typeof CreateWikiPageSchema> = { 289 | organizationId, 290 | projectId: projectName, 291 | wikiId, 292 | pagePath: testPagePathDefault, 293 | content: 'Content for page created with default path.', 294 | }; 295 | 296 | const createdPage = await createWikiPage(params); 297 | 298 | // Verify the result 299 | expect(createdPage).toBeDefined(); 300 | expect(createdPage.path).toBe(testPagePathDefault); 301 | expect(createdPage.content).toBe(params.content); 302 | 303 | // Verify by fetching the page 304 | const fetchedPage = await getWikiPage({ 305 | organizationId, 306 | projectId: projectName, 307 | wikiId, 308 | pagePath: testPagePathDefault, 309 | }); 310 | 311 | expect(fetchedPage).toBeDefined(); 312 | expect(typeof fetchedPage).toBe('string'); 313 | expect(fetchedPage).toContain(params.content); 314 | } catch (error) { 315 | console.error('Error in test:', error); 316 | throw error; 317 | } 318 | }); 319 | 320 | test('should include comment in the wiki page creation when provided', async () => { 321 | // Skip if no connection is available 322 | if (shouldSkipIntegrationTest()) { 323 | console.log('Skipping test due to missing connection'); 324 | return; 325 | } 326 | 327 | // This connection must be available if we didn't skip 328 | if (!connection) { 329 | throw new Error( 330 | 'Connection should be available when test is not skipped', 331 | ); 332 | } 333 | 334 | // Get a valid wiki ID 335 | const wikiId = await getValidWikiId(); 336 | if (!wikiId) { 337 | console.log('Skipping test: No valid wiki ID available'); 338 | return; 339 | } 340 | 341 | try { 342 | const params: z.infer<typeof CreateWikiPageSchema> = { 343 | organizationId, 344 | projectId: projectName, 345 | wikiId, 346 | pagePath: testPagePathComment, 347 | content: 'Content with comment.', 348 | comment: 'This is a test comment for the wiki page creation', 349 | }; 350 | 351 | const createdPage = await createWikiPage(params); 352 | 353 | // Verify the result 354 | expect(createdPage).toBeDefined(); 355 | expect(createdPage.path).toBe(testPagePathComment); 356 | expect(createdPage.content).toBe(params.content); 357 | 358 | // Verify by fetching the page 359 | const fetchedPage = await getWikiPage({ 360 | organizationId, 361 | projectId: projectName, 362 | wikiId, 363 | pagePath: testPagePathComment, 364 | }); 365 | 366 | expect(fetchedPage).toBeDefined(); 367 | expect(typeof fetchedPage).toBe('string'); 368 | expect(fetchedPage).toContain(params.content); 369 | 370 | // Note: The API might not return the comment in the response 371 | // This test primarily verifies that including a comment doesn't break the API call 372 | } catch (error) { 373 | console.error('Error in test:', error); 374 | throw error; 375 | } 376 | }); 377 | 378 | test('should handle error when wiki does not exist', async () => { 379 | // Skip if no connection is available 380 | if (shouldSkipIntegrationTest()) { 381 | console.log('Skipping test due to missing connection'); 382 | return; 383 | } 384 | 385 | const nonExistentWikiId = 'non-existent-wiki-12345'; 386 | 387 | const params: z.infer<typeof CreateWikiPageSchema> = { 388 | organizationId, 389 | projectId: projectName, 390 | wikiId: nonExistentWikiId, 391 | pagePath: '/test-page', 392 | content: 'This should fail.', 393 | }; 394 | 395 | await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); 396 | }); 397 | 398 | test('should handle error when project does not exist', async () => { 399 | // Skip if no connection is available 400 | if (shouldSkipIntegrationTest()) { 401 | console.log('Skipping test due to missing connection'); 402 | return; 403 | } 404 | 405 | const nonExistentProjectId = 'non-existent-project-12345'; 406 | 407 | const params: z.infer<typeof CreateWikiPageSchema> = { 408 | organizationId, 409 | projectId: nonExistentProjectId, 410 | wikiId: 'any-wiki', 411 | pagePath: '/test-page', 412 | content: 'This should fail.', 413 | }; 414 | 415 | await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); 416 | }); 417 | 418 | test('should handle error when organization does not exist', async () => { 419 | // Skip if no connection is available 420 | if (shouldSkipIntegrationTest()) { 421 | console.log('Skipping test due to missing connection'); 422 | return; 423 | } 424 | 425 | const nonExistentOrgId = 'non-existent-org-12345'; 426 | 427 | const params: z.infer<typeof CreateWikiPageSchema> = { 428 | organizationId: nonExistentOrgId, 429 | projectId: projectName, 430 | wikiId: 'any-wiki', 431 | pagePath: '/test-page', 432 | content: 'This should fail.', 433 | }; 434 | 435 | await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); 436 | }); 437 | }); 438 | ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { getProjectDetails } from './feature'; 2 | import { 3 | AzureDevOpsError, 4 | AzureDevOpsResourceNotFoundError, 5 | } from '../../../shared/errors'; 6 | import { 7 | TeamProject, 8 | WebApiTeam, 9 | } from 'azure-devops-node-api/interfaces/CoreInterfaces'; 10 | import { WebApi } from 'azure-devops-node-api'; 11 | import { WorkItemType } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; 12 | 13 | // Create mock interfaces for the APIs we'll use 14 | interface MockCoreApi { 15 | getProject: jest.Mock<Promise<TeamProject | null>>; 16 | getTeams: jest.Mock<Promise<WebApiTeam[]>>; 17 | } 18 | 19 | interface MockWorkItemTrackingApi { 20 | getWorkItemTypes: jest.Mock<Promise<WorkItemType[]>>; 21 | } 22 | 23 | interface MockProcessApi { 24 | getProcesses: jest.Mock<Promise<any[]>>; 25 | getProcessWorkItemTypes: jest.Mock<Promise<any[]>>; 26 | } 27 | 28 | // Create a mock connection that resembles WebApi with minimal implementation 29 | interface MockConnection { 30 | getCoreApi: jest.Mock<Promise<MockCoreApi>>; 31 | getWorkItemTrackingApi: jest.Mock<Promise<MockWorkItemTrackingApi>>; 32 | getProcessApi: jest.Mock<Promise<MockProcessApi>>; 33 | serverUrl?: string; 34 | authHandler?: unknown; 35 | rest?: unknown; 36 | vsoClient?: unknown; 37 | } 38 | 39 | // Sample data for tests 40 | const mockProject = { 41 | id: 'project-id', 42 | name: 'Test Project', 43 | description: 'A test project', 44 | url: 'https://dev.azure.com/org/project', 45 | state: 1, // wellFormed 46 | revision: 123, 47 | visibility: 0, // private 48 | lastUpdateTime: new Date(), 49 | capabilities: { 50 | versioncontrol: { 51 | sourceControlType: 'Git', 52 | }, 53 | processTemplate: { 54 | templateName: 'Agile', 55 | templateTypeId: 'template-guid', 56 | }, 57 | }, 58 | } as unknown as TeamProject; 59 | 60 | const mockTeams: WebApiTeam[] = [ 61 | { 62 | id: 'team-guid-1', 63 | name: 'Team 1', 64 | description: 'First team', 65 | url: 'https://dev.azure.com/org/_apis/projects/project-guid/teams/team-guid-1', 66 | identityUrl: 'https://vssps.dev.azure.com/org/_apis/Identities/team-guid-1', 67 | } as WebApiTeam, 68 | { 69 | id: 'team-guid-2', 70 | name: 'Team 2', 71 | description: 'Second team', 72 | url: 'https://dev.azure.com/org/_apis/projects/project-guid/teams/team-guid-2', 73 | identityUrl: 'https://vssps.dev.azure.com/org/_apis/Identities/team-guid-2', 74 | } as WebApiTeam, 75 | ]; 76 | 77 | const mockWorkItemTypes: WorkItemType[] = [ 78 | { 79 | name: 'User Story', 80 | description: 'Tracks user requirements', 81 | referenceName: 'Microsoft.VSTS.WorkItemTypes.UserStory', 82 | color: 'blue', 83 | icon: 'icon-user-story', 84 | isDisabled: false, 85 | } as WorkItemType, 86 | { 87 | name: 'Bug', 88 | description: 'Tracks defects in the product', 89 | referenceName: 'Microsoft.VSTS.WorkItemTypes.Bug', 90 | color: 'red', 91 | icon: 'icon-bug', 92 | isDisabled: false, 93 | } as WorkItemType, 94 | ]; 95 | 96 | const mockProcesses = [ 97 | { 98 | id: 'process-guid', 99 | name: 'Agile', 100 | description: 'Agile process', 101 | isDefault: true, 102 | type: 'system', 103 | }, 104 | ]; 105 | 106 | const mockProcessWorkItemTypes = [ 107 | { 108 | name: 'User Story', 109 | referenceName: 'Microsoft.VSTS.WorkItemTypes.UserStory', 110 | description: 'Tracks user requirements', 111 | color: 'blue', 112 | icon: 'icon-user-story', 113 | isDisabled: false, 114 | states: [ 115 | { 116 | name: 'New', 117 | color: 'blue', 118 | stateCategory: 'Proposed', 119 | }, 120 | { 121 | name: 'Active', 122 | color: 'blue', 123 | stateCategory: 'InProgress', 124 | }, 125 | { 126 | name: 'Resolved', 127 | color: 'blue', 128 | stateCategory: 'InProgress', 129 | }, 130 | { 131 | name: 'Closed', 132 | color: 'blue', 133 | stateCategory: 'Completed', 134 | }, 135 | ], 136 | fields: [ 137 | { 138 | name: 'Title', 139 | referenceName: 'System.Title', 140 | type: 'string', 141 | required: true, 142 | }, 143 | { 144 | name: 'Description', 145 | referenceName: 'System.Description', 146 | type: 'html', 147 | }, 148 | ], 149 | }, 150 | { 151 | name: 'Bug', 152 | referenceName: 'Microsoft.VSTS.WorkItemTypes.Bug', 153 | description: 'Tracks defects in the product', 154 | color: 'red', 155 | icon: 'icon-bug', 156 | isDisabled: false, 157 | states: [ 158 | { 159 | name: 'New', 160 | color: 'red', 161 | stateCategory: 'Proposed', 162 | }, 163 | { 164 | name: 'Active', 165 | color: 'red', 166 | stateCategory: 'InProgress', 167 | }, 168 | { 169 | name: 'Resolved', 170 | color: 'red', 171 | stateCategory: 'InProgress', 172 | }, 173 | { 174 | name: 'Closed', 175 | color: 'red', 176 | stateCategory: 'Completed', 177 | }, 178 | ], 179 | fields: [ 180 | { 181 | name: 'Title', 182 | referenceName: 'System.Title', 183 | type: 'string', 184 | required: true, 185 | }, 186 | { 187 | name: 'Repro Steps', 188 | referenceName: 'Microsoft.VSTS.TCM.ReproSteps', 189 | type: 'html', 190 | }, 191 | ], 192 | }, 193 | ]; 194 | 195 | // Unit tests should only focus on isolated logic 196 | describe('getProjectDetails unit', () => { 197 | test('should throw resource not found error when project is null', async () => { 198 | // Arrange 199 | const mockCoreApi: MockCoreApi = { 200 | getProject: jest.fn().mockResolvedValue(null), // Simulate project not found 201 | getTeams: jest.fn().mockResolvedValue([]), 202 | }; 203 | 204 | const mockConnection: MockConnection = { 205 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 206 | getWorkItemTrackingApi: jest.fn().mockResolvedValue({ 207 | getWorkItemTypes: jest.fn().mockResolvedValue([]), 208 | }), 209 | getProcessApi: jest.fn().mockResolvedValue({ 210 | getProcesses: jest.fn().mockResolvedValue([]), 211 | getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), 212 | }), 213 | }; 214 | 215 | // Act & Assert 216 | await expect( 217 | getProjectDetails(mockConnection as unknown as WebApi, { 218 | projectId: 'non-existent-project', 219 | }), 220 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 221 | 222 | await expect( 223 | getProjectDetails(mockConnection as unknown as WebApi, { 224 | projectId: 'non-existent-project', 225 | }), 226 | ).rejects.toThrow("Project 'non-existent-project' not found"); 227 | }); 228 | 229 | test('should return basic project details when no additional options are specified', async () => { 230 | // Arrange 231 | const mockCoreApi: MockCoreApi = { 232 | getProject: jest.fn().mockResolvedValue(mockProject), 233 | getTeams: jest.fn().mockResolvedValue([]), 234 | }; 235 | 236 | const mockConnection: MockConnection = { 237 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 238 | getWorkItemTrackingApi: jest.fn().mockResolvedValue({ 239 | getWorkItemTypes: jest.fn().mockResolvedValue([]), 240 | }), 241 | getProcessApi: jest.fn().mockResolvedValue({ 242 | getProcesses: jest.fn().mockResolvedValue([]), 243 | getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), 244 | }), 245 | }; 246 | 247 | // Act 248 | const result = await getProjectDetails( 249 | mockConnection as unknown as WebApi, 250 | { 251 | projectId: 'test-project', 252 | }, 253 | ); 254 | 255 | // Assert 256 | expect(result).toBeDefined(); 257 | expect(result.id).toBe(mockProject.id); 258 | expect(result.name).toBe(mockProject.name); 259 | expect(result.description).toBe(mockProject.description); 260 | expect(result.url).toBe(mockProject.url); 261 | expect(result.state).toBe(mockProject.state); 262 | expect(result.revision).toBe(mockProject.revision); 263 | expect(result.visibility).toBe(mockProject.visibility); 264 | expect(result.lastUpdateTime).toBe(mockProject.lastUpdateTime); 265 | expect(result.capabilities).toEqual(mockProject.capabilities); 266 | 267 | // Verify that additional details are not included 268 | expect(result.process).toBeUndefined(); 269 | expect(result.teams).toBeUndefined(); 270 | }); 271 | 272 | test('should include teams when includeTeams is true', async () => { 273 | // Arrange 274 | const mockCoreApi: MockCoreApi = { 275 | getProject: jest.fn().mockResolvedValue(mockProject), 276 | getTeams: jest.fn().mockResolvedValue(mockTeams), 277 | }; 278 | 279 | const mockConnection: MockConnection = { 280 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 281 | getWorkItemTrackingApi: jest.fn().mockResolvedValue({ 282 | getWorkItemTypes: jest.fn().mockResolvedValue([]), 283 | }), 284 | getProcessApi: jest.fn().mockResolvedValue({ 285 | getProcesses: jest.fn().mockResolvedValue([]), 286 | getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), 287 | }), 288 | }; 289 | 290 | // Act 291 | const result = await getProjectDetails( 292 | mockConnection as unknown as WebApi, 293 | { 294 | projectId: 'test-project', 295 | includeTeams: true, 296 | }, 297 | ); 298 | 299 | // Assert 300 | expect(result).toBeDefined(); 301 | expect(result.teams).toBeDefined(); 302 | expect(result.teams?.length).toBe(2); 303 | expect(result.teams?.[0].id).toBe(mockTeams[0].id); 304 | expect(result.teams?.[0].name).toBe(mockTeams[0].name); 305 | expect(result.teams?.[1].id).toBe(mockTeams[1].id); 306 | expect(result.teams?.[1].name).toBe(mockTeams[1].name); 307 | }); 308 | 309 | test('should include process information when includeProcess is true', async () => { 310 | // Arrange 311 | const mockCoreApi: MockCoreApi = { 312 | getProject: jest.fn().mockResolvedValue(mockProject), 313 | getTeams: jest.fn().mockResolvedValue([]), 314 | }; 315 | 316 | const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { 317 | getWorkItemTypes: jest.fn().mockResolvedValue([]), 318 | }; 319 | 320 | const mockConnection: MockConnection = { 321 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 322 | getWorkItemTrackingApi: jest 323 | .fn() 324 | .mockResolvedValue(mockWorkItemTrackingApi), 325 | getProcessApi: jest.fn(), 326 | }; 327 | 328 | // Act 329 | const result = await getProjectDetails( 330 | mockConnection as unknown as WebApi, 331 | { 332 | projectId: 'test-project', 333 | includeProcess: true, 334 | }, 335 | ); 336 | 337 | // Assert 338 | expect(result).toBeDefined(); 339 | expect(result.process).toBeDefined(); 340 | expect(result.process?.name).toBe('Agile'); 341 | }); 342 | 343 | test('should include work item types when includeWorkItemTypes is true', async () => { 344 | // Arrange 345 | const mockCoreApi: MockCoreApi = { 346 | getProject: jest.fn().mockResolvedValue(mockProject), 347 | getTeams: jest.fn().mockResolvedValue([]), 348 | }; 349 | 350 | const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { 351 | getWorkItemTypes: jest.fn().mockResolvedValue(mockWorkItemTypes), 352 | }; 353 | 354 | const mockProcessApi: MockProcessApi = { 355 | getProcesses: jest.fn().mockResolvedValue(mockProcesses), 356 | getProcessWorkItemTypes: jest 357 | .fn() 358 | .mockResolvedValue(mockProcessWorkItemTypes), 359 | }; 360 | 361 | const mockConnection: MockConnection = { 362 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 363 | getWorkItemTrackingApi: jest 364 | .fn() 365 | .mockResolvedValue(mockWorkItemTrackingApi), 366 | getProcessApi: jest.fn().mockResolvedValue(mockProcessApi), 367 | }; 368 | 369 | // Act 370 | const result = await getProjectDetails( 371 | mockConnection as unknown as WebApi, 372 | { 373 | projectId: 'test-project', 374 | includeWorkItemTypes: true, 375 | includeProcess: true, 376 | }, 377 | ); 378 | 379 | // Assert 380 | expect(result).toBeDefined(); 381 | expect(result.process).toBeDefined(); 382 | expect(result.process?.workItemTypes).toBeDefined(); 383 | expect(result.process?.workItemTypes?.length).toBe(2); 384 | expect(result.process?.workItemTypes?.[0].name).toBe('User Story'); 385 | expect(result.process?.workItemTypes?.[1].name).toBe('Bug'); 386 | }); 387 | 388 | test('should include fields when includeFields is true', async () => { 389 | // Arrange 390 | const mockCoreApi: MockCoreApi = { 391 | getProject: jest.fn().mockResolvedValue(mockProject), 392 | getTeams: jest.fn().mockResolvedValue([]), 393 | }; 394 | 395 | const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { 396 | getWorkItemTypes: jest.fn().mockResolvedValue(mockWorkItemTypes), 397 | }; 398 | 399 | const mockProcessApi: MockProcessApi = { 400 | getProcesses: jest.fn().mockResolvedValue(mockProcesses), 401 | getProcessWorkItemTypes: jest 402 | .fn() 403 | .mockResolvedValue(mockProcessWorkItemTypes), 404 | }; 405 | 406 | const mockConnection: MockConnection = { 407 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), 408 | getWorkItemTrackingApi: jest 409 | .fn() 410 | .mockResolvedValue(mockWorkItemTrackingApi), 411 | getProcessApi: jest.fn().mockResolvedValue(mockProcessApi), 412 | }; 413 | 414 | // Act 415 | const result = await getProjectDetails( 416 | mockConnection as unknown as WebApi, 417 | { 418 | projectId: 'test-project', 419 | includeWorkItemTypes: true, 420 | includeFields: true, 421 | includeProcess: true, 422 | }, 423 | ); 424 | 425 | // Assert 426 | expect(result).toBeDefined(); 427 | expect(result.process).toBeDefined(); 428 | expect(result.process?.workItemTypes).toBeDefined(); 429 | expect(result.process?.workItemTypes?.[0].fields).toBeDefined(); 430 | expect(result.process?.workItemTypes?.[0].fields?.length).toBe(2); 431 | expect(result.process?.workItemTypes?.[0].fields?.[0].name).toBe('Title'); 432 | expect(result.process?.workItemTypes?.[0].fields?.[1].name).toBe( 433 | 'Description', 434 | ); 435 | }); 436 | 437 | test('should propagate custom errors when thrown internally', async () => { 438 | // Arrange 439 | const mockConnection: MockConnection = { 440 | getCoreApi: jest.fn().mockImplementation(() => { 441 | throw new AzureDevOpsError('Custom error'); 442 | }), 443 | getWorkItemTrackingApi: jest.fn(), 444 | getProcessApi: jest.fn(), 445 | }; 446 | 447 | // Act & Assert 448 | await expect( 449 | getProjectDetails(mockConnection as unknown as WebApi, { 450 | projectId: 'test-project', 451 | }), 452 | ).rejects.toThrow(AzureDevOpsError); 453 | 454 | await expect( 455 | getProjectDetails(mockConnection as unknown as WebApi, { 456 | projectId: 'test-project', 457 | }), 458 | ).rejects.toThrow('Custom error'); 459 | }); 460 | 461 | test('should wrap unexpected errors in a friendly error message', async () => { 462 | // Arrange 463 | const mockConnection: MockConnection = { 464 | getCoreApi: jest.fn().mockImplementation(() => { 465 | throw new Error('Unexpected error'); 466 | }), 467 | getWorkItemTrackingApi: jest.fn(), 468 | getProcessApi: jest.fn(), 469 | }; 470 | 471 | // Act & Assert 472 | await expect( 473 | getProjectDetails(mockConnection as unknown as WebApi, { 474 | projectId: 'test-project', 475 | }), 476 | ).rejects.toThrow('Failed to get project details: Unexpected error'); 477 | }); 478 | }); 479 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 2 | import { 3 | CallToolRequestSchema, 4 | ListToolsRequestSchema, 5 | ListResourcesRequestSchema, 6 | ReadResourceRequestSchema, 7 | } from '@modelcontextprotocol/sdk/types.js'; 8 | import { WebApi } from 'azure-devops-node-api'; 9 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 10 | import { VERSION } from './shared/config'; 11 | import { AzureDevOpsConfig } from './shared/types'; 12 | import { 13 | AzureDevOpsAuthenticationError, 14 | AzureDevOpsError, 15 | AzureDevOpsResourceNotFoundError, 16 | AzureDevOpsValidationError, 17 | } from './shared/errors'; 18 | import { handleResponseError } from './shared/errors/handle-request-error'; 19 | import { AuthenticationMethod, AzureDevOpsClient } from './shared/auth'; 20 | // Import environment defaults when needed in feature handlers 21 | 22 | // Import feature modules with request handlers and tool definitions 23 | import { 24 | workItemsTools, 25 | isWorkItemsRequest, 26 | handleWorkItemsRequest, 27 | } from './features/work-items'; 28 | 29 | import { 30 | projectsTools, 31 | isProjectsRequest, 32 | handleProjectsRequest, 33 | } from './features/projects'; 34 | 35 | import { 36 | repositoriesTools, 37 | isRepositoriesRequest, 38 | handleRepositoriesRequest, 39 | } from './features/repositories'; 40 | 41 | import { 42 | organizationsTools, 43 | isOrganizationsRequest, 44 | handleOrganizationsRequest, 45 | } from './features/organizations'; 46 | 47 | import { 48 | searchTools, 49 | isSearchRequest, 50 | handleSearchRequest, 51 | } from './features/search'; 52 | 53 | import { 54 | usersTools, 55 | isUsersRequest, 56 | handleUsersRequest, 57 | } from './features/users'; 58 | 59 | import { 60 | pullRequestsTools, 61 | isPullRequestsRequest, 62 | handlePullRequestsRequest, 63 | } from './features/pull-requests'; 64 | 65 | import { 66 | pipelinesTools, 67 | isPipelinesRequest, 68 | handlePipelinesRequest, 69 | } from './features/pipelines'; 70 | 71 | import { 72 | wikisTools, 73 | isWikisRequest, 74 | handleWikisRequest, 75 | } from './features/wikis'; 76 | 77 | // Create a safe console logging function that won't interfere with MCP protocol 78 | function safeLog(message: string) { 79 | process.stderr.write(`${message}\n`); 80 | } 81 | 82 | /** 83 | * Type definition for the Azure DevOps MCP Server 84 | */ 85 | export type AzureDevOpsServer = Server; 86 | 87 | /** 88 | * Create an Azure DevOps MCP Server 89 | * 90 | * @param config The Azure DevOps configuration 91 | * @returns A configured MCP server instance 92 | */ 93 | export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { 94 | // Validate the configuration 95 | validateConfig(config); 96 | 97 | // Initialize the MCP server 98 | const server = new Server( 99 | { 100 | name: 'azure-devops-mcp', 101 | version: VERSION, 102 | }, 103 | { 104 | capabilities: { 105 | tools: {}, 106 | resources: {}, 107 | }, 108 | }, 109 | ); 110 | 111 | // Register the ListTools request handler 112 | server.setRequestHandler(ListToolsRequestSchema, () => { 113 | // Combine tools from all features 114 | const tools = [ 115 | ...usersTools, 116 | ...organizationsTools, 117 | ...projectsTools, 118 | ...repositoriesTools, 119 | ...workItemsTools, 120 | ...searchTools, 121 | ...pullRequestsTools, 122 | ...pipelinesTools, 123 | ...wikisTools, 124 | ]; 125 | 126 | return { tools }; 127 | }); 128 | 129 | // Register the resource handlers 130 | // ListResources - register available resource templates 131 | server.setRequestHandler(ListResourcesRequestSchema, async () => { 132 | // Create resource templates for repository content 133 | const templates = [ 134 | // Default branch content 135 | { 136 | uriTemplate: 'ado://{organization}/{project}/{repo}/contents{/path*}', 137 | name: 'Repository Content', 138 | description: 'Content from the default branch of a repository', 139 | }, 140 | // Branch specific content 141 | { 142 | uriTemplate: 143 | 'ado://{organization}/{project}/{repo}/branches/{branch}/contents{/path*}', 144 | name: 'Branch Content', 145 | description: 'Content from a specific branch of a repository', 146 | }, 147 | // Commit specific content 148 | { 149 | uriTemplate: 150 | 'ado://{organization}/{project}/{repo}/commits/{commit}/contents{/path*}', 151 | name: 'Commit Content', 152 | description: 'Content from a specific commit in a repository', 153 | }, 154 | // Tag specific content 155 | { 156 | uriTemplate: 157 | 'ado://{organization}/{project}/{repo}/tags/{tag}/contents{/path*}', 158 | name: 'Tag Content', 159 | description: 'Content from a specific tag in a repository', 160 | }, 161 | // Pull request specific content 162 | { 163 | uriTemplate: 164 | 'ado://{organization}/{project}/{repo}/pullrequests/{prId}/contents{/path*}', 165 | name: 'Pull Request Content', 166 | description: 'Content from a specific pull request in a repository', 167 | }, 168 | ]; 169 | 170 | return { 171 | resources: [], 172 | templates, 173 | }; 174 | }); 175 | 176 | // ReadResource - handle reading content from the templates 177 | server.setRequestHandler(ReadResourceRequestSchema, async (request) => { 178 | try { 179 | const uri = new URL(request.params.uri); 180 | 181 | // Parse the URI to extract components 182 | const segments = uri.pathname.split('/').filter(Boolean); 183 | 184 | // Check if it's an Azure DevOps resource URI 185 | if (uri.protocol !== 'ado:') { 186 | throw new AzureDevOpsResourceNotFoundError( 187 | `Unsupported protocol: ${uri.protocol}`, 188 | ); 189 | } 190 | 191 | // Extract organization, project, and repo 192 | // const organization = segments[0]; // Currently unused but kept for future use 193 | const project = segments[1]; 194 | const repo = segments[2]; 195 | 196 | // Get a connection to Azure DevOps 197 | const connection = await getConnection(config); 198 | 199 | // Default path is root if not specified 200 | let path = '/'; 201 | // Extract path from the remaining segments, if there are at least 5 segments (org/project/repo/contents/path) 202 | if (segments.length >= 5 && segments[3] === 'contents') { 203 | path = '/' + segments.slice(4).join('/'); 204 | } 205 | 206 | // Determine version control parameters based on URI pattern 207 | let versionType: number | undefined; 208 | let version: string | undefined; 209 | 210 | if (segments[3] === 'branches' && segments.length >= 5) { 211 | versionType = GitVersionType.Branch; 212 | version = segments[4]; 213 | 214 | // Extract path if present 215 | if (segments.length >= 7 && segments[5] === 'contents') { 216 | path = '/' + segments.slice(6).join('/'); 217 | } 218 | } else if (segments[3] === 'commits' && segments.length >= 5) { 219 | versionType = GitVersionType.Commit; 220 | version = segments[4]; 221 | 222 | // Extract path if present 223 | if (segments.length >= 7 && segments[5] === 'contents') { 224 | path = '/' + segments.slice(6).join('/'); 225 | } 226 | } else if (segments[3] === 'tags' && segments.length >= 5) { 227 | versionType = GitVersionType.Tag; 228 | version = segments[4]; 229 | 230 | // Extract path if present 231 | if (segments.length >= 7 && segments[5] === 'contents') { 232 | path = '/' + segments.slice(6).join('/'); 233 | } 234 | } else if (segments[3] === 'pullrequests' && segments.length >= 5) { 235 | // TODO: For PR head, we need to get the source branch or commit 236 | // Currently just use the default branch as a fallback 237 | // versionType = GitVersionType.Branch; 238 | // version = 'PR-' + segments[4]; 239 | 240 | // Extract path if present 241 | if (segments.length >= 7 && segments[5] === 'contents') { 242 | path = '/' + segments.slice(6).join('/'); 243 | } 244 | } 245 | 246 | // Get the content 247 | const versionDescriptor = 248 | versionType && version ? { versionType, version } : undefined; 249 | 250 | // Import the getFileContent function from repositories feature 251 | const { getFileContent } = await import( 252 | './features/repositories/get-file-content/index.js' 253 | ); 254 | 255 | const fileContent = await getFileContent( 256 | connection, 257 | project, 258 | repo, 259 | path, 260 | versionDescriptor, 261 | ); 262 | 263 | // Return the content based on whether it's a file or directory 264 | return { 265 | contents: [ 266 | { 267 | uri: request.params.uri, 268 | mimeType: fileContent.isDirectory 269 | ? 'application/json' 270 | : getMimeType(path), 271 | text: fileContent.content, 272 | }, 273 | ], 274 | }; 275 | } catch (error) { 276 | safeLog(`Error reading resource: ${error}`); 277 | if (error instanceof AzureDevOpsError) { 278 | throw error; 279 | } 280 | throw new AzureDevOpsResourceNotFoundError( 281 | `Failed to read resource: ${error instanceof Error ? error.message : String(error)}`, 282 | ); 283 | } 284 | }); 285 | 286 | // Register the CallTool request handler 287 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 288 | try { 289 | // Note: We don't need to validate the presence of arguments here because: 290 | // 1. The schema validations (via zod.parse) will check for required parameters 291 | // 2. Default values from environment.ts are applied for optional parameters (projectId, organizationId) 292 | // 3. Arguments can be omitted entirely for tools with no required parameters 293 | 294 | // Get a connection to Azure DevOps 295 | const connection = await getConnection(config); 296 | 297 | // Route the request to the appropriate feature handler 298 | if (isWorkItemsRequest(request)) { 299 | return await handleWorkItemsRequest(connection, request); 300 | } 301 | 302 | if (isProjectsRequest(request)) { 303 | return await handleProjectsRequest(connection, request); 304 | } 305 | 306 | if (isRepositoriesRequest(request)) { 307 | return await handleRepositoriesRequest(connection, request); 308 | } 309 | 310 | if (isOrganizationsRequest(request)) { 311 | // Organizations feature doesn't need the config object anymore 312 | return await handleOrganizationsRequest(connection, request); 313 | } 314 | 315 | if (isSearchRequest(request)) { 316 | return await handleSearchRequest(connection, request); 317 | } 318 | 319 | if (isUsersRequest(request)) { 320 | return await handleUsersRequest(connection, request); 321 | } 322 | 323 | if (isPullRequestsRequest(request)) { 324 | return await handlePullRequestsRequest(connection, request); 325 | } 326 | 327 | if (isPipelinesRequest(request)) { 328 | return await handlePipelinesRequest(connection, request); 329 | } 330 | 331 | if (isWikisRequest(request)) { 332 | return await handleWikisRequest(connection, request); 333 | } 334 | 335 | // If we get here, the tool is not recognized by any feature handler 336 | throw new Error(`Unknown tool: ${request.params.name}`); 337 | } catch (error) { 338 | return handleResponseError(error); 339 | } 340 | }); 341 | 342 | return server; 343 | } 344 | 345 | /** 346 | * Get a mime type based on file extension 347 | * 348 | * @param path File path 349 | * @returns Mime type string 350 | */ 351 | function getMimeType(path: string): string { 352 | const extension = path.split('.').pop()?.toLowerCase(); 353 | 354 | switch (extension) { 355 | case 'txt': 356 | return 'text/plain'; 357 | case 'html': 358 | case 'htm': 359 | return 'text/html'; 360 | case 'css': 361 | return 'text/css'; 362 | case 'js': 363 | return 'application/javascript'; 364 | case 'json': 365 | return 'application/json'; 366 | case 'xml': 367 | return 'application/xml'; 368 | case 'md': 369 | return 'text/markdown'; 370 | case 'png': 371 | return 'image/png'; 372 | case 'jpg': 373 | case 'jpeg': 374 | return 'image/jpeg'; 375 | case 'gif': 376 | return 'image/gif'; 377 | case 'webp': 378 | return 'image/webp'; 379 | case 'svg': 380 | return 'image/svg+xml'; 381 | case 'pdf': 382 | return 'application/pdf'; 383 | case 'ts': 384 | case 'tsx': 385 | return 'application/typescript'; 386 | case 'py': 387 | return 'text/x-python'; 388 | case 'cs': 389 | return 'text/x-csharp'; 390 | case 'java': 391 | return 'text/x-java'; 392 | case 'c': 393 | return 'text/x-c'; 394 | case 'cpp': 395 | case 'cc': 396 | return 'text/x-c++'; 397 | case 'go': 398 | return 'text/x-go'; 399 | case 'rs': 400 | return 'text/x-rust'; 401 | case 'rb': 402 | return 'text/x-ruby'; 403 | case 'sh': 404 | return 'text/x-sh'; 405 | case 'yaml': 406 | case 'yml': 407 | return 'text/yaml'; 408 | default: 409 | return 'text/plain'; 410 | } 411 | } 412 | 413 | /** 414 | * Validate the Azure DevOps configuration 415 | * 416 | * @param config The configuration to validate 417 | * @throws {AzureDevOpsValidationError} If the configuration is invalid 418 | */ 419 | function validateConfig(config: AzureDevOpsConfig): void { 420 | if (!config.organizationUrl) { 421 | process.stderr.write( 422 | 'ERROR: Organization URL is required but was not provided.\n', 423 | ); 424 | process.stderr.write( 425 | `Config: ${JSON.stringify( 426 | { 427 | organizationUrl: config.organizationUrl, 428 | authMethod: config.authMethod, 429 | defaultProject: config.defaultProject, 430 | // Hide PAT for security 431 | personalAccessToken: config.personalAccessToken 432 | ? 'REDACTED' 433 | : undefined, 434 | apiVersion: config.apiVersion, 435 | }, 436 | null, 437 | 2, 438 | )}\n`, 439 | ); 440 | throw new AzureDevOpsValidationError('Organization URL is required'); 441 | } 442 | 443 | // Set default authentication method if not specified 444 | if (!config.authMethod) { 445 | config.authMethod = AuthenticationMethod.AzureIdentity; 446 | } 447 | 448 | // Validate PAT if using PAT authentication 449 | if ( 450 | config.authMethod === AuthenticationMethod.PersonalAccessToken && 451 | !config.personalAccessToken 452 | ) { 453 | throw new AzureDevOpsValidationError( 454 | 'Personal access token is required when using PAT authentication', 455 | ); 456 | } 457 | } 458 | 459 | /** 460 | * Create a connection to Azure DevOps 461 | * 462 | * @param config The configuration to use 463 | * @returns A WebApi connection 464 | */ 465 | export async function getConnection( 466 | config: AzureDevOpsConfig, 467 | ): Promise<WebApi> { 468 | try { 469 | // Create a client with the appropriate authentication method 470 | const client = new AzureDevOpsClient({ 471 | method: config.authMethod || AuthenticationMethod.AzureIdentity, 472 | organizationUrl: config.organizationUrl, 473 | personalAccessToken: config.personalAccessToken, 474 | }); 475 | 476 | // Test the connection by getting the Core API 477 | await client.getCoreApi(); 478 | 479 | // Return the underlying WebApi client 480 | return await client.getWebApiClient(); 481 | } catch (error) { 482 | throw new AzureDevOpsAuthenticationError( 483 | `Failed to connect to Azure DevOps: ${error instanceof Error ? error.message : String(error)}`, 484 | ); 485 | } 486 | } 487 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { updatePullRequest } from './feature'; 3 | import { createPullRequest } from '../create-pull-request/feature'; 4 | import { listWorkItems } from '../../work-items/list-work-items/feature'; 5 | import { 6 | getTestConnection, 7 | shouldSkipIntegrationTest, 8 | } from '@/shared/test/test-helpers'; 9 | 10 | describe('updatePullRequest integration', () => { 11 | let connection: WebApi | null = null; 12 | let projectName: string; 13 | let repositoryName: string; 14 | let pullRequestId: number; 15 | let workItemId: number | null = null; 16 | 17 | // Generate unique identifiers using timestamp 18 | const timestamp = Date.now(); 19 | const randomSuffix = Math.floor(Math.random() * 1000); 20 | const uniqueBranchName = `test-branch-${timestamp}-${randomSuffix}`; 21 | const uniqueTitle = `Test PR ${timestamp}-${randomSuffix}`; 22 | const updatedTitle = `Updated PR ${timestamp}-${randomSuffix}`; 23 | 24 | beforeAll(async () => { 25 | // Skip if integration tests should be skipped 26 | if (shouldSkipIntegrationTest()) { 27 | return; 28 | } 29 | 30 | // Get a real connection using environment variables 31 | connection = await getTestConnection(); 32 | 33 | // Get project and repository names from environment variables 34 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 35 | repositoryName = 36 | process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || 'DefaultRepo'; 37 | 38 | // Find an existing work item to use in tests 39 | if (!connection) { 40 | throw new Error('Connection is null'); 41 | } 42 | const workItems = await listWorkItems(connection, { 43 | projectId: projectName, 44 | top: 1, // Just need one work item 45 | }); 46 | 47 | if (workItems && workItems.length > 0 && workItems[0].id) { 48 | workItemId = workItems[0].id; 49 | } 50 | 51 | // Create a test pull request or find an existing one 52 | const gitApi = await connection.getGitApi(); 53 | 54 | // Get the default branch's object ID 55 | const repository = await gitApi.getRepository(repositoryName, projectName); 56 | const defaultBranch = 57 | repository.defaultBranch?.replace('refs/heads/', '') || 'main'; 58 | 59 | // Get the latest commit on the default branch 60 | const commits = await gitApi.getCommits( 61 | repositoryName, 62 | { 63 | $top: 1, 64 | itemVersion: { 65 | version: defaultBranch, 66 | versionType: 0, // 0 = branch 67 | }, 68 | }, 69 | projectName, 70 | ); 71 | 72 | if (!commits || commits.length === 0) { 73 | throw new Error('No commits found in repository'); 74 | } 75 | 76 | // Create a new branch 77 | const refUpdate = { 78 | name: `refs/heads/${uniqueBranchName}`, 79 | oldObjectId: '0000000000000000000000000000000000000000', 80 | newObjectId: commits[0].commitId, 81 | }; 82 | 83 | const updateResult = await gitApi.updateRefs( 84 | [refUpdate], 85 | repositoryName, 86 | projectName, 87 | ); 88 | 89 | if ( 90 | !updateResult || 91 | updateResult.length === 0 || 92 | !updateResult[0].success 93 | ) { 94 | throw new Error('Failed to create new branch'); 95 | } 96 | 97 | // Create a test pull request 98 | const testPullRequest = await createPullRequest( 99 | connection, 100 | projectName, 101 | repositoryName, 102 | { 103 | title: uniqueTitle, 104 | description: 'Test pull request for integration testing', 105 | sourceRefName: `refs/heads/${uniqueBranchName}`, 106 | targetRefName: repository.defaultBranch || 'refs/heads/main', 107 | isDraft: true, 108 | }, 109 | ); 110 | 111 | pullRequestId = testPullRequest.pullRequestId!; 112 | }); 113 | 114 | afterAll(async () => { 115 | // Clean up created resources 116 | if (!shouldSkipIntegrationTest() && connection && pullRequestId) { 117 | try { 118 | // Check the current state of the pull request 119 | const gitApi = await connection.getGitApi(); 120 | const pullRequest = await gitApi.getPullRequestById( 121 | pullRequestId, 122 | projectName, 123 | ); 124 | 125 | // Only try to abandon if it's still active (status 1) 126 | if (pullRequest && pullRequest.status === 1) { 127 | await gitApi.updatePullRequest( 128 | { 129 | status: 2, // 2 = Abandoned 130 | }, 131 | repositoryName, 132 | pullRequestId, 133 | projectName, 134 | ); 135 | } 136 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 137 | } catch (_) { 138 | // Ignore cleanup errors 139 | } 140 | } 141 | }); 142 | 143 | test('should update pull request title and description', async () => { 144 | // Skip if integration tests should be skipped 145 | if (shouldSkipIntegrationTest() || !connection) { 146 | console.log('Skipping test due to missing connection'); 147 | return; 148 | } 149 | 150 | const updatedDescription = 'Updated description for integration testing'; 151 | 152 | const result = await updatePullRequest({ 153 | projectId: projectName, 154 | repositoryId: repositoryName, 155 | pullRequestId, 156 | title: updatedTitle, 157 | description: updatedDescription, 158 | }); 159 | 160 | // Verify the update was successful 161 | expect(result).toBeDefined(); 162 | expect(result.pullRequestId).toBe(pullRequestId); 163 | expect(result.title).toBe(updatedTitle); 164 | expect(result.description).toBe(updatedDescription); 165 | }, 30000); // 30 second timeout for integration test 166 | 167 | test('should update pull request draft status', async () => { 168 | // Skip if integration tests should be skipped 169 | if (shouldSkipIntegrationTest() || !connection) { 170 | console.log('Skipping test due to missing connection'); 171 | return; 172 | } 173 | 174 | // Mark as not a draft 175 | const result = await updatePullRequest({ 176 | projectId: projectName, 177 | repositoryId: repositoryName, 178 | pullRequestId, 179 | isDraft: false, 180 | }); 181 | 182 | // Verify the update was successful 183 | expect(result).toBeDefined(); 184 | expect(result.pullRequestId).toBe(pullRequestId); 185 | expect(result.isDraft).toBe(false); 186 | }, 30000); // 30 second timeout for integration test 187 | 188 | test('should add work item links to pull request', async () => { 189 | // Skip if no work items were found 190 | if (shouldSkipIntegrationTest()) { 191 | console.log('Skipping test due to missing connection or work item'); 192 | return; 193 | } 194 | 195 | // Add the work item link 196 | const result = await updatePullRequest({ 197 | projectId: projectName, 198 | repositoryId: repositoryName, 199 | pullRequestId, 200 | addWorkItemIds: [workItemId!], 201 | }); 202 | 203 | // Verify the update was successful 204 | expect(result).toBeDefined(); 205 | expect(result.pullRequestId).toBe(pullRequestId); 206 | 207 | // Get the pull request work items using the proper API 208 | const gitApi = await connection!.getGitApi(); 209 | 210 | // Add a delay to allow Azure DevOps to process the work item link 211 | await new Promise((resolve) => setTimeout(resolve, 5000)); 212 | 213 | // Use the getPullRequestWorkItemRefs method to get the work items 214 | const workItemRefs = await gitApi.getPullRequestWorkItemRefs( 215 | repositoryName, 216 | pullRequestId, 217 | projectName, 218 | ); 219 | 220 | // Verify that work items are linked 221 | expect(workItemRefs).toBeDefined(); 222 | expect(Array.isArray(workItemRefs)).toBe(true); 223 | 224 | // Check if our work item is in the list 225 | const hasWorkItem = workItemRefs.some( 226 | (ref) => ref.id !== undefined && Number(ref.id) === workItemId, 227 | ); 228 | expect(hasWorkItem).toBe(true); 229 | }, 60000); // 60 second timeout for integration test 230 | 231 | test('should remove work item links from pull request', async () => { 232 | // Skip if no work items were found 233 | if (shouldSkipIntegrationTest()) { 234 | console.log('Skipping test due to missing connection or work item'); 235 | return; 236 | } 237 | 238 | // First ensure the work item is linked 239 | try { 240 | await updatePullRequest({ 241 | projectId: projectName, 242 | repositoryId: repositoryName, 243 | pullRequestId, 244 | addWorkItemIds: [workItemId!], 245 | }); 246 | 247 | // Add a delay to allow Azure DevOps to process the work item link 248 | await new Promise((resolve) => setTimeout(resolve, 3000)); 249 | } catch (error) { 250 | // If there's an error adding the link, that's okay 251 | console.log( 252 | "Error adding work item (already be linked so that's 👍):", 253 | error instanceof Error ? error.message : String(error), 254 | ); 255 | } 256 | 257 | // Then remove the work item link 258 | const result = await updatePullRequest({ 259 | projectId: projectName, 260 | repositoryId: repositoryName, 261 | pullRequestId, 262 | removeWorkItemIds: [workItemId!], 263 | }); 264 | 265 | // Verify the update was successful 266 | expect(result).toBeDefined(); 267 | expect(result.pullRequestId).toBe(pullRequestId); 268 | 269 | // Get the pull request work items using the proper API 270 | const gitApi = await connection!.getGitApi(); 271 | 272 | // Add a delay to allow Azure DevOps to process the work item unlink 273 | await new Promise((resolve) => setTimeout(resolve, 5000)); 274 | 275 | // Use the getPullRequestWorkItemRefs method to get the work items 276 | const workItemRefs = await gitApi.getPullRequestWorkItemRefs( 277 | repositoryName, 278 | pullRequestId, 279 | projectName, 280 | ); 281 | 282 | // Verify that work items are properly unlinked 283 | expect(workItemRefs).toBeDefined(); 284 | expect(Array.isArray(workItemRefs)).toBe(true); 285 | 286 | // Check if our work item is not in the list 287 | const hasWorkItem = workItemRefs.some( 288 | (ref) => ref.id !== undefined && Number(ref.id) === workItemId, 289 | ); 290 | expect(hasWorkItem).toBe(false); 291 | }, 60000); // 60 second timeout for integration test 292 | 293 | test('should add reviewers to pull request', async () => { 294 | // Skip if integration tests should be skipped 295 | if (shouldSkipIntegrationTest() || !connection) { 296 | console.log('Skipping test due to missing connection'); 297 | return; 298 | } 299 | 300 | // Find an actual user in the organization to use as a reviewer 301 | const gitApi = await connection.getGitApi(); 302 | 303 | // Get the pull request creator as a reviewer (they always exist) 304 | const pullRequest = await gitApi.getPullRequestById( 305 | pullRequestId, 306 | projectName, 307 | )!; 308 | 309 | // Use the pull request creator's ID as the reviewer 310 | const reviewer = pullRequest.createdBy!.id!; 311 | 312 | // Add the reviewer 313 | const result = await updatePullRequest({ 314 | projectId: projectName, 315 | repositoryId: repositoryName, 316 | pullRequestId, 317 | addReviewers: [reviewer], 318 | }); 319 | 320 | // Verify the update was successful 321 | expect(result).toBeDefined(); 322 | expect(result.pullRequestId).toBe(pullRequestId); 323 | 324 | // Add a delay to allow Azure DevOps to process the reviewer addition 325 | await new Promise((resolve) => setTimeout(resolve, 1000)); 326 | 327 | const reviewers = await gitApi.getPullRequestReviewers( 328 | repositoryName, 329 | pullRequestId, 330 | projectName, 331 | ); 332 | 333 | // Verify that the reviewer was added 334 | expect(reviewers).toBeDefined(); 335 | expect(Array.isArray(reviewers)).toBe(true); 336 | 337 | // Check if our reviewer is in the list by ID 338 | const hasReviewer = reviewers.some((r) => r.id === reviewer); 339 | expect(hasReviewer).toBe(true); 340 | }, 60000); // 60 second timeout for integration test 341 | 342 | test('should remove reviewers from pull request', async () => { 343 | // Skip if integration tests should be skipped 344 | if (shouldSkipIntegrationTest() || !connection) { 345 | console.log('Skipping test due to missing connection'); 346 | return; 347 | } 348 | 349 | // Find an actual user in the organization to use as a reviewer 350 | const gitApi = await connection.getGitApi(); 351 | 352 | // Get the pull request creator as a reviewer (they always exist) 353 | const pullRequest = await gitApi.getPullRequestById( 354 | pullRequestId, 355 | projectName, 356 | ); 357 | 358 | if (!pullRequest || !pullRequest.createdBy || !pullRequest.createdBy.id) { 359 | throw new Error('Could not determine pull request creator'); 360 | } 361 | 362 | // Use the pull request creator's ID as the reviewer 363 | const reviewer = pullRequest.createdBy.id; 364 | 365 | // First ensure the reviewer is added 366 | try { 367 | await updatePullRequest({ 368 | projectId: projectName, 369 | repositoryId: repositoryName, 370 | pullRequestId, 371 | addReviewers: [reviewer], 372 | }); 373 | 374 | // Add a delay to allow Azure DevOps to process the reviewer addition 375 | await new Promise((resolve) => setTimeout(resolve, 3000)); 376 | } catch (error) { 377 | // If there's an error adding the reviewer, that's okay 378 | console.log( 379 | 'Error adding reviewer (might already be added):', 380 | error instanceof Error ? error.message : String(error), 381 | ); 382 | } 383 | 384 | // Then remove the reviewer 385 | const result = await updatePullRequest({ 386 | projectId: projectName, 387 | repositoryId: repositoryName, 388 | pullRequestId, 389 | removeReviewers: [reviewer], 390 | }); 391 | 392 | // Verify the update was successful 393 | expect(result).toBeDefined(); 394 | expect(result.pullRequestId).toBe(pullRequestId); 395 | 396 | // Add a delay to allow Azure DevOps to process the reviewer removal 397 | await new Promise((resolve) => setTimeout(resolve, 3000)); 398 | 399 | const reviewers = await gitApi.getPullRequestReviewers( 400 | repositoryName, 401 | pullRequestId, 402 | projectName, 403 | ); 404 | 405 | // Verify that the reviewer was removed 406 | expect(reviewers).toBeDefined(); 407 | expect(Array.isArray(reviewers)).toBe(true); 408 | 409 | // Check if our reviewer is not in the list 410 | const hasReviewer = reviewers.some((r) => r.id === reviewer); 411 | expect(hasReviewer).toBe(false); 412 | }, 60000); // 60 second timeout for integration test 413 | 414 | test('should update pull request with additional properties', async () => { 415 | // Skip if integration tests should be skipped 416 | if (shouldSkipIntegrationTest() || !connection) { 417 | console.log('Skipping test due to missing connection'); 418 | return; 419 | } 420 | 421 | // Use a custom property that Azure DevOps supports 422 | const customProperty = 'autoComplete'; 423 | const customValue = true; 424 | 425 | const result = await updatePullRequest({ 426 | projectId: projectName, 427 | repositoryId: repositoryName, 428 | pullRequestId, 429 | additionalProperties: { 430 | [customProperty]: customValue, 431 | }, 432 | }); 433 | 434 | // Verify the update was successful 435 | expect(result).toBeDefined(); 436 | expect(result.pullRequestId).toBe(pullRequestId); 437 | 438 | // For autoComplete specifically, we can check if it's in the response 439 | if (customProperty in result) { 440 | expect(result[customProperty]).toBe(customValue); 441 | } 442 | }, 30000); // 30 second timeout for integration test 443 | 444 | test('should update pull request status to abandoned', async () => { 445 | // Skip if integration tests should be skipped 446 | if (shouldSkipIntegrationTest() || !connection) { 447 | console.log('Skipping test due to missing connection'); 448 | return; 449 | } 450 | 451 | // Abandon the pull request instead of completing it 452 | // Completing requires additional setup that's complex for integration tests 453 | const result = await updatePullRequest({ 454 | projectId: projectName, 455 | repositoryId: repositoryName, 456 | pullRequestId, 457 | status: 'abandoned', 458 | }); 459 | 460 | // Verify the update was successful 461 | expect(result).toBeDefined(); 462 | expect(result.pullRequestId).toBe(pullRequestId); 463 | expect(result.status).toBe(2); // 2 = Abandoned 464 | }, 30000); // 30 second timeout for integration test 465 | }); 466 | ``` -------------------------------------------------------------------------------- /src/features/wikis/list-wiki-pages/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { listWikiPages, WikiPageSummary } from './feature'; 2 | import * as azureDevOpsClient from '../../../clients/azure-devops'; 3 | import { 4 | AzureDevOpsError, 5 | AzureDevOpsResourceNotFoundError, 6 | AzureDevOpsPermissionError, 7 | } from '../../../shared/errors/azure-devops-errors'; 8 | 9 | // Mock the Azure DevOps client 10 | jest.mock('../../../clients/azure-devops'); 11 | 12 | // Mock the environment utilities to avoid dependency on environment variables 13 | jest.mock('../../../utils/environment', () => ({ 14 | defaultOrg: 'azure-devops-mcp-testing', 15 | defaultProject: 'eShopOnWeb', 16 | })); 17 | 18 | describe('listWikiPages unit', () => { 19 | // Mock WikiClient 20 | const mockWikiClient = { 21 | listWikiPages: jest.fn(), 22 | }; 23 | 24 | // Mock getWikiClient function 25 | const mockGetWikiClient = 26 | azureDevOpsClient.getWikiClient as jest.MockedFunction< 27 | typeof azureDevOpsClient.getWikiClient 28 | >; 29 | 30 | beforeEach(() => { 31 | // Clear mock calls between tests 32 | jest.clearAllMocks(); 33 | 34 | // Setup default mock implementation 35 | mockGetWikiClient.mockResolvedValue(mockWikiClient as any); 36 | }); 37 | 38 | describe('Happy Path Scenarios', () => { 39 | test('should return wiki pages successfully', async () => { 40 | // Mock data 41 | const mockPages: WikiPageSummary[] = [ 42 | { 43 | id: 1, 44 | path: '/Home', 45 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/1', 46 | order: 1, 47 | }, 48 | { 49 | id: 2, 50 | path: '/Getting-Started', 51 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/2', 52 | order: 2, 53 | }, 54 | ]; 55 | 56 | // Setup mock responses 57 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 58 | 59 | // Call the function 60 | const result = await listWikiPages({ 61 | organizationId: 'test-org', 62 | projectId: 'test-project', 63 | wikiId: 'test-wiki', 64 | }); 65 | 66 | // Assertions 67 | expect(mockGetWikiClient).toHaveBeenCalledWith({ 68 | organizationId: 'test-org', 69 | }); 70 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 71 | 'test-project', 72 | 'test-wiki', 73 | ); 74 | expect(result).toEqual(mockPages); 75 | expect(result.length).toBe(2); 76 | }); 77 | 78 | test('should handle basic listing without parameters', async () => { 79 | const mockPages: WikiPageSummary[] = [ 80 | { 81 | id: 3, 82 | path: '/docs/api', 83 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/3', 84 | order: 1, 85 | }, 86 | ]; 87 | 88 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 89 | 90 | const result = await listWikiPages({ 91 | organizationId: 'test-org', 92 | projectId: 'test-project', 93 | wikiId: 'test-wiki', 94 | }); 95 | 96 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 97 | 'test-project', 98 | 'test-wiki', 99 | ); 100 | expect(result).toEqual(mockPages); 101 | }); 102 | 103 | test('should handle nested pages correctly', async () => { 104 | const mockPages: WikiPageSummary[] = [ 105 | { 106 | id: 4, 107 | path: '/deep/nested/page', 108 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/4', 109 | order: 1, 110 | }, 111 | ]; 112 | 113 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 114 | 115 | const result = await listWikiPages({ 116 | organizationId: 'test-org', 117 | projectId: 'test-project', 118 | wikiId: 'test-wiki', 119 | }); 120 | 121 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 122 | 'test-project', 123 | 'test-wiki', 124 | ); 125 | expect(result).toEqual(mockPages); 126 | }); 127 | 128 | test('should handle empty wiki correctly', async () => { 129 | const mockPages: WikiPageSummary[] = []; 130 | 131 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 132 | 133 | const result = await listWikiPages({ 134 | organizationId: 'test-org', 135 | projectId: 'test-project', 136 | wikiId: 'test-wiki', 137 | }); 138 | 139 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 140 | 'test-project', 141 | 'test-wiki', 142 | ); 143 | expect(result).toEqual(mockPages); 144 | }); 145 | 146 | test('should return empty array when no pages found', async () => { 147 | mockWikiClient.listWikiPages.mockResolvedValue([]); 148 | 149 | const result = await listWikiPages({ 150 | organizationId: 'test-org', 151 | projectId: 'test-project', 152 | wikiId: 'empty-wiki', 153 | }); 154 | 155 | expect(result).toEqual([]); 156 | expect(Array.isArray(result)).toBe(true); 157 | }); 158 | 159 | test('should use default organization and project when not provided', async () => { 160 | const mockPages: WikiPageSummary[] = [ 161 | { 162 | id: 5, 163 | path: '/default-page', 164 | url: 'https://dev.azure.com/default-org/default-project/_wiki/wikis/wiki1/5', 165 | order: 1, 166 | }, 167 | ]; 168 | 169 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 170 | 171 | const result = await listWikiPages({ 172 | wikiId: 'test-wiki', 173 | }); 174 | 175 | expect(mockGetWikiClient).toHaveBeenCalledWith({ 176 | organizationId: 'azure-devops-mcp-testing', // Uses default from environment 177 | }); 178 | expect(result).toEqual(mockPages); 179 | }); 180 | }); 181 | 182 | describe('Error Scenarios', () => { 183 | test('should handle network timeout errors', async () => { 184 | const timeoutError = new Error('Network timeout'); 185 | timeoutError.name = 'ETIMEDOUT'; 186 | mockWikiClient.listWikiPages.mockRejectedValue(timeoutError); 187 | 188 | await expect( 189 | listWikiPages({ 190 | organizationId: 'test-org', 191 | projectId: 'test-project', 192 | wikiId: 'test-wiki', 193 | }), 194 | ).rejects.toThrow(AzureDevOpsError); 195 | }); 196 | 197 | test('should handle connection refused errors', async () => { 198 | const connectionError = new Error('Connection refused'); 199 | connectionError.name = 'ECONNREFUSED'; 200 | mockWikiClient.listWikiPages.mockRejectedValue(connectionError); 201 | 202 | await expect( 203 | listWikiPages({ 204 | organizationId: 'test-org', 205 | projectId: 'test-project', 206 | wikiId: 'test-wiki', 207 | }), 208 | ).rejects.toThrow(AzureDevOpsError); 209 | }); 210 | 211 | test('should propagate AzureDevOpsResourceNotFoundError from client', async () => { 212 | const notFoundError = new AzureDevOpsResourceNotFoundError( 213 | 'Wiki not found: test-wiki', 214 | ); 215 | mockWikiClient.listWikiPages.mockRejectedValue(notFoundError); 216 | 217 | await expect( 218 | listWikiPages({ 219 | organizationId: 'test-org', 220 | projectId: 'test-project', 221 | wikiId: 'non-existent-wiki', 222 | }), 223 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 224 | }); 225 | 226 | test('should propagate AzureDevOpsPermissionError from client', async () => { 227 | const permissionError = new AzureDevOpsPermissionError( 228 | 'Permission denied to access wiki', 229 | ); 230 | mockWikiClient.listWikiPages.mockRejectedValue(permissionError); 231 | 232 | await expect( 233 | listWikiPages({ 234 | organizationId: 'test-org', 235 | projectId: 'test-project', 236 | wikiId: 'restricted-wiki', 237 | }), 238 | ).rejects.toThrow(AzureDevOpsPermissionError); 239 | }); 240 | 241 | test('should wrap unknown errors in AzureDevOpsError', async () => { 242 | const unknownError = new Error('Unknown error occurred'); 243 | mockWikiClient.listWikiPages.mockRejectedValue(unknownError); 244 | 245 | await expect( 246 | listWikiPages({ 247 | organizationId: 'test-org', 248 | projectId: 'test-project', 249 | wikiId: 'test-wiki', 250 | }), 251 | ).rejects.toThrow(AzureDevOpsError); 252 | 253 | try { 254 | await listWikiPages({ 255 | organizationId: 'test-org', 256 | projectId: 'test-project', 257 | wikiId: 'test-wiki', 258 | }); 259 | } catch (error) { 260 | expect(error).toBeInstanceOf(AzureDevOpsError); 261 | expect((error as AzureDevOpsError).message).toBe( 262 | 'Failed to list wiki pages', 263 | ); 264 | } 265 | }); 266 | 267 | test('should handle client creation failure', async () => { 268 | const clientError = new Error('Failed to create client'); 269 | mockGetWikiClient.mockRejectedValue(clientError); 270 | 271 | await expect( 272 | listWikiPages({ 273 | organizationId: 'invalid-org', 274 | projectId: 'test-project', 275 | wikiId: 'test-wiki', 276 | }), 277 | ).rejects.toThrow(AzureDevOpsError); 278 | }); 279 | }); 280 | 281 | describe('Edge Cases and Input Validation', () => { 282 | test('should handle malformed API response gracefully', async () => { 283 | // Mock malformed response (missing required fields) 284 | const malformedPages = [ 285 | { 286 | id: 'invalid-id', // Should be number 287 | path: null, // Should be string 288 | url: undefined, // Should be string 289 | }, 290 | ]; 291 | 292 | mockWikiClient.listWikiPages.mockResolvedValue(malformedPages as any); 293 | 294 | const result = await listWikiPages({ 295 | organizationId: 'test-org', 296 | projectId: 'test-project', 297 | wikiId: 'test-wiki', 298 | }); 299 | 300 | // Should still return the data as-is (transformation happens in client) 301 | expect(result).toEqual(malformedPages); 302 | }); 303 | 304 | test('should handle null/undefined response from client', async () => { 305 | mockWikiClient.listWikiPages.mockResolvedValue(null as any); 306 | 307 | await expect( 308 | listWikiPages({ 309 | organizationId: 'test-org', 310 | projectId: 'test-project', 311 | wikiId: 'test-wiki', 312 | }), 313 | ).rejects.toThrow(AzureDevOpsError); 314 | }); 315 | 316 | test('should handle very large page collections', async () => { 317 | // Create a large mock dataset 318 | const largeMockPages: WikiPageSummary[] = Array.from( 319 | { length: 10000 }, 320 | (_, i) => ({ 321 | id: i + 1, 322 | path: `/page-${i + 1}`, 323 | url: `https://dev.azure.com/org/project/_wiki/wikis/wiki1/${i + 1}`, 324 | order: i + 1, 325 | }), 326 | ); 327 | 328 | mockWikiClient.listWikiPages.mockResolvedValue(largeMockPages); 329 | 330 | const result = await listWikiPages({ 331 | organizationId: 'test-org', 332 | projectId: 'test-project', 333 | wikiId: 'large-wiki', 334 | }); 335 | 336 | expect(result).toEqual(largeMockPages); 337 | expect(result.length).toBe(10000); 338 | }); 339 | 340 | test('should handle pages with special characters in paths', async () => { 341 | const specialCharPages: WikiPageSummary[] = [ 342 | { 343 | id: 1, 344 | path: '/页面-中文', 345 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/1', 346 | order: 1, 347 | }, 348 | { 349 | id: 2, 350 | path: '/página-español', 351 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/2', 352 | order: 2, 353 | }, 354 | { 355 | id: 3, 356 | path: '/page with spaces & symbols!@#$%', 357 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/3', 358 | order: 3, 359 | }, 360 | ]; 361 | 362 | mockWikiClient.listWikiPages.mockResolvedValue(specialCharPages); 363 | 364 | const result = await listWikiPages({ 365 | organizationId: 'test-org', 366 | projectId: 'test-project', 367 | wikiId: 'special-wiki', 368 | }); 369 | 370 | expect(result).toEqual(specialCharPages); 371 | }); 372 | 373 | test('should handle pages with missing optional order field', async () => { 374 | const pagesWithoutOrder: WikiPageSummary[] = [ 375 | { 376 | id: 1, 377 | path: '/page-1', 378 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/1', 379 | // order field is optional and missing 380 | } as WikiPageSummary, 381 | { 382 | id: 2, 383 | path: '/page-2', 384 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/2', 385 | order: 5, 386 | }, 387 | ]; 388 | 389 | mockWikiClient.listWikiPages.mockResolvedValue(pagesWithoutOrder); 390 | 391 | const result = await listWikiPages({ 392 | organizationId: 'test-org', 393 | projectId: 'test-project', 394 | wikiId: 'test-wiki', 395 | }); 396 | 397 | expect(result).toEqual(pagesWithoutOrder); 398 | expect(result[0].order).toBeUndefined(); 399 | expect(result[1].order).toBe(5); 400 | }); 401 | }); 402 | 403 | describe('Parameter Validation Edge Cases', () => { 404 | test('should handle basic parameter validation', async () => { 405 | const mockPages: WikiPageSummary[] = []; 406 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 407 | 408 | await listWikiPages({ 409 | organizationId: 'test-org', 410 | projectId: 'test-project', 411 | wikiId: 'test-wiki', 412 | }); 413 | 414 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 415 | 'test-project', 416 | 'test-wiki', 417 | ); 418 | 419 | await listWikiPages({ 420 | organizationId: 'test-org', 421 | projectId: 'test-project', 422 | wikiId: 'test-wiki', 423 | }); 424 | 425 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 426 | 'test-project', 427 | 'test-wiki', 428 | ); 429 | }); 430 | 431 | test('should handle empty string parameters', async () => { 432 | const mockPages: WikiPageSummary[] = []; 433 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 434 | 435 | await listWikiPages({ 436 | organizationId: '', 437 | projectId: '', 438 | wikiId: 'test-wiki', 439 | }); 440 | 441 | expect(mockGetWikiClient).toHaveBeenCalledWith({ 442 | organizationId: 'azure-devops-mcp-testing', // Empty string gets overridden by default 443 | }); 444 | expect(mockWikiClient.listWikiPages).toHaveBeenCalledWith( 445 | 'eShopOnWeb', // Empty string gets overridden by default project 446 | 'test-wiki', 447 | ); 448 | }); 449 | }); 450 | 451 | describe('Data Transformation and Mapping', () => { 452 | test('should preserve all WikiPageSummary fields correctly', async () => { 453 | const mockPages: WikiPageSummary[] = [ 454 | { 455 | id: 42, 456 | path: '/test-page', 457 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/42', 458 | order: 10, 459 | }, 460 | ]; 461 | 462 | mockWikiClient.listWikiPages.mockResolvedValue(mockPages); 463 | 464 | const result = await listWikiPages({ 465 | organizationId: 'test-org', 466 | projectId: 'test-project', 467 | wikiId: 'test-wiki', 468 | }); 469 | 470 | expect(result[0]).toEqual({ 471 | id: 42, 472 | path: '/test-page', 473 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/42', 474 | order: 10, 475 | }); 476 | }); 477 | 478 | test('should handle mixed data types in response', async () => { 479 | const mixedPages = [ 480 | { 481 | id: 1, 482 | path: '/normal-page', 483 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/1', 484 | order: 1, 485 | }, 486 | { 487 | id: 2, 488 | path: '/page-without-order', 489 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/2', 490 | // order is undefined 491 | }, 492 | { 493 | id: 3, 494 | path: '/page-with-zero-order', 495 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1/3', 496 | order: 0, 497 | }, 498 | ]; 499 | 500 | mockWikiClient.listWikiPages.mockResolvedValue( 501 | mixedPages as WikiPageSummary[], 502 | ); 503 | 504 | const result = await listWikiPages({ 505 | organizationId: 'test-org', 506 | projectId: 'test-project', 507 | wikiId: 'test-wiki', 508 | }); 509 | 510 | expect(result).toEqual(mixedPages); 511 | expect(result[1].order).toBeUndefined(); 512 | expect(result[2].order).toBe(0); 513 | }); 514 | }); 515 | }); 516 | ``` -------------------------------------------------------------------------------- /src/features/wikis/list-wiki-pages/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { listWikiPages, WikiPageSummary } from './feature'; 2 | import { getWikis } from '../get-wikis/feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '@/shared/test/test-helpers'; 7 | import { getOrgNameFromUrl } from '@/utils/environment'; 8 | import { AzureDevOpsError } from '@/shared/errors/azure-devops-errors'; 9 | 10 | // Ensure environment variables are set for testing 11 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = 12 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'default-project'; 13 | 14 | describe('listWikiPages integration', () => { 15 | let projectName: string; 16 | let orgUrl: string; 17 | let organizationId: string; 18 | 19 | beforeAll(async () => { 20 | // Mock the required environment variable for testing 21 | process.env.AZURE_DEVOPS_ORG_URL = 22 | process.env.AZURE_DEVOPS_ORG_URL || 'https://example.visualstudio.com'; 23 | 24 | // Get and validate required environment variables 25 | const envProjectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; 26 | if (!envProjectName) { 27 | throw new Error( 28 | 'AZURE_DEVOPS_DEFAULT_PROJECT environment variable is required', 29 | ); 30 | } 31 | projectName = envProjectName; 32 | 33 | const envOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; 34 | if (!envOrgUrl) { 35 | throw new Error('AZURE_DEVOPS_ORG_URL environment variable is required'); 36 | } 37 | orgUrl = envOrgUrl; 38 | organizationId = getOrgNameFromUrl(orgUrl); 39 | }); 40 | 41 | describe('Happy Path Tests', () => { 42 | test('should list pages in real test wiki', async () => { 43 | // Skip if no connection available 44 | if (shouldSkipIntegrationTest()) { 45 | return; 46 | } 47 | 48 | // Get a real connection using environment variables 49 | const connection = await getTestConnection(); 50 | if (!connection) { 51 | throw new Error( 52 | 'Connection should be available when test is not skipped', 53 | ); 54 | } 55 | 56 | // First get available wikis 57 | const wikis = await getWikis(connection, { projectId: projectName }); 58 | 59 | // Skip if no wikis are available 60 | if (wikis.length === 0) { 61 | console.log('Skipping test: No wikis available in the project'); 62 | return; 63 | } 64 | 65 | // Use the first available wiki 66 | const wiki = wikis[0]; 67 | if (!wiki.name) { 68 | throw new Error('Wiki name is undefined'); 69 | } 70 | 71 | // List wiki pages 72 | const result = await listWikiPages({ 73 | organizationId, 74 | projectId: projectName, 75 | wikiId: wiki.name, 76 | }); 77 | 78 | // Verify the result structure 79 | expect(result).toBeDefined(); 80 | expect(Array.isArray(result)).toBe(true); 81 | 82 | // If pages exist, verify their structure matches WikiPageSummary interface 83 | if (result.length > 0) { 84 | const page = result[0]; 85 | expect(page).toHaveProperty('id'); 86 | expect(page).toHaveProperty('path'); 87 | expect(page).toHaveProperty('url'); 88 | expect(typeof page.id).toBe('number'); 89 | expect(typeof page.path).toBe('string'); 90 | // url and order are optional 91 | if (page.url !== undefined) { 92 | expect(typeof page.url).toBe('string'); 93 | } 94 | if (page.order !== undefined) { 95 | expect(typeof page.order).toBe('number'); 96 | } 97 | } 98 | }); 99 | 100 | test('should handle wiki listing for different wiki structures', async () => { 101 | // Skip if integration tests are disabled or no connection available 102 | if (shouldSkipIntegrationTest()) { 103 | return; 104 | } 105 | 106 | // Get a real connection using environment variables 107 | const connection = await getTestConnection(); 108 | if (!connection) { 109 | throw new Error( 110 | 'Connection should be available when test is not skipped', 111 | ); 112 | } 113 | 114 | // First get available wikis 115 | const wikis = await getWikis(connection, { projectId: projectName }); 116 | 117 | // Skip if no wikis are available 118 | if (wikis.length === 0) { 119 | console.log('Skipping test: No wikis available in the project'); 120 | return; 121 | } 122 | 123 | // Use the first available wiki 124 | const wiki = wikis[0]; 125 | if (!wiki.name) { 126 | throw new Error('Wiki name is undefined'); 127 | } 128 | 129 | // Get all pages for different wiki structures 130 | const allPages = await listWikiPages({ 131 | organizationId, 132 | projectId: projectName, 133 | wikiId: wiki.name, 134 | }); 135 | 136 | expect(Array.isArray(allPages)).toBe(true); 137 | 138 | // If we have pages, verify they have expected structure 139 | if (allPages.length > 0) { 140 | const firstPage = allPages[0]; 141 | expect(firstPage).toHaveProperty('id'); 142 | expect(firstPage).toHaveProperty('path'); 143 | expect(firstPage).toHaveProperty('url'); 144 | 145 | // Verify nested pages if they exist 146 | const nestedPages = allPages.filter( 147 | (page) => page.path.includes('/') && page.path !== '/', 148 | ); 149 | console.log( 150 | `Found ${nestedPages.length} nested pages out of ${allPages.length} total pages`, 151 | ); 152 | } 153 | }); 154 | 155 | test('should handle basic wiki page listing consistently', async () => { 156 | // Skip if integration tests are disabled or no connection available 157 | if (shouldSkipIntegrationTest()) { 158 | return; 159 | } 160 | 161 | // Get a real connection using environment variables 162 | const connection = await getTestConnection(); 163 | if (!connection) { 164 | throw new Error( 165 | 'Connection should be available when test is not skipped', 166 | ); 167 | } 168 | 169 | // First get available wikis 170 | const wikis = await getWikis(connection, { projectId: projectName }); 171 | 172 | // Skip if no wikis are available 173 | if (wikis.length === 0) { 174 | console.log('Skipping test: No wikis available in the project'); 175 | return; 176 | } 177 | 178 | // Use the first available wiki 179 | const wiki = wikis[0]; 180 | if (!wiki.name) { 181 | throw new Error('Wiki name is undefined'); 182 | } 183 | 184 | // Test basic page listing 185 | const firstResult = await listWikiPages({ 186 | organizationId, 187 | projectId: projectName, 188 | wikiId: wiki.name, 189 | }); 190 | 191 | expect(Array.isArray(firstResult)).toBe(true); 192 | 193 | // Test again to ensure consistency 194 | const secondResult = await listWikiPages({ 195 | organizationId, 196 | projectId: projectName, 197 | wikiId: wiki.name, 198 | }); 199 | 200 | expect(Array.isArray(secondResult)).toBe(true); 201 | 202 | // Results should be consistent 203 | expect(secondResult.length).toBe(firstResult.length); 204 | }); 205 | }); 206 | 207 | describe('Error Scenarios', () => { 208 | test('should handle invalid wikiId (expect 404 error)', async () => { 209 | // Skip if integration tests are disabled or no connection available 210 | if (shouldSkipIntegrationTest()) { 211 | return; 212 | } 213 | 214 | const invalidWikiId = 'non-existent-wiki-id-12345'; 215 | 216 | await expect( 217 | listWikiPages({ 218 | organizationId, 219 | projectId: projectName, 220 | wikiId: invalidWikiId, 221 | }), 222 | ).rejects.toThrow(AzureDevOpsError); 223 | }); 224 | 225 | test('should handle invalid projectId', async () => { 226 | // Skip if integration tests are disabled or no connection available 227 | if (shouldSkipIntegrationTest()) { 228 | return; 229 | } 230 | 231 | const invalidProjectId = 'non-existent-project-12345'; 232 | 233 | await expect( 234 | listWikiPages({ 235 | organizationId, 236 | projectId: invalidProjectId, 237 | wikiId: 'any-wiki', 238 | }), 239 | ).rejects.toThrow(AzureDevOpsError); 240 | }); 241 | 242 | test('should handle invalid organizationId', async () => { 243 | // Skip if integration tests are disabled or no connection available 244 | if (shouldSkipIntegrationTest()) { 245 | return; 246 | } 247 | 248 | const invalidOrgId = 'non-existent-org-12345'; 249 | 250 | await expect( 251 | listWikiPages({ 252 | organizationId: invalidOrgId, 253 | projectId: projectName, 254 | wikiId: 'any-wiki', 255 | }), 256 | ).rejects.toThrow(AzureDevOpsError); 257 | }); 258 | }); 259 | 260 | describe('Edge Cases', () => { 261 | test('should handle empty wikis gracefully', async () => { 262 | // Skip if integration tests are disabled or no connection available 263 | if (shouldSkipIntegrationTest()) { 264 | return; 265 | } 266 | 267 | // Get a real connection using environment variables 268 | const connection = await getTestConnection(); 269 | if (!connection) { 270 | throw new Error( 271 | 'Connection should be available when test is not skipped', 272 | ); 273 | } 274 | 275 | // First get available wikis 276 | const wikis = await getWikis(connection, { projectId: projectName }); 277 | 278 | // Skip if no wikis are available 279 | if (wikis.length === 0) { 280 | console.log('Skipping test: No wikis available in the project'); 281 | return; 282 | } 283 | 284 | // Use the first available wiki 285 | const wiki = wikis[0]; 286 | if (!wiki.name) { 287 | throw new Error('Wiki name is undefined'); 288 | } 289 | 290 | // Test with a path that likely doesn't exist 291 | const result = await listWikiPages({ 292 | organizationId, 293 | projectId: projectName, 294 | wikiId: wiki.name, 295 | }); 296 | 297 | // Should return an array (may be empty or contain all pages depending on API behavior) 298 | expect(Array.isArray(result)).toBe(true); 299 | // Note: Azure DevOps API may return all pages when path doesn't match 300 | console.log(`Path filter test returned ${result.length} pages`); 301 | }); 302 | 303 | test('should handle deeply nested paths', async () => { 304 | // Skip if integration tests are disabled or no connection available 305 | if (shouldSkipIntegrationTest()) { 306 | return; 307 | } 308 | 309 | // Get a real connection using environment variables 310 | const connection = await getTestConnection(); 311 | if (!connection) { 312 | throw new Error( 313 | 'Connection should be available when test is not skipped', 314 | ); 315 | } 316 | 317 | // First get available wikis 318 | const wikis = await getWikis(connection, { projectId: projectName }); 319 | 320 | // Skip if no wikis are available 321 | if (wikis.length === 0) { 322 | console.log('Skipping test: No wikis available in the project'); 323 | return; 324 | } 325 | 326 | // Use the first available wiki 327 | const wiki = wikis[0]; 328 | if (!wiki.name) { 329 | throw new Error('Wiki name is undefined'); 330 | } 331 | 332 | // Test with default parameters 333 | const result = await listWikiPages({ 334 | organizationId, 335 | projectId: projectName, 336 | wikiId: wiki.name, 337 | }); 338 | 339 | expect(Array.isArray(result)).toBe(true); 340 | // Should not throw error with basic parameters 341 | }); 342 | 343 | test('should handle boundary recursionLevel values', async () => { 344 | // Skip if integration tests are disabled or no connection available 345 | if (shouldSkipIntegrationTest()) { 346 | return; 347 | } 348 | 349 | // Get a real connection using environment variables 350 | const connection = await getTestConnection(); 351 | if (!connection) { 352 | throw new Error( 353 | 'Connection should be available when test is not skipped', 354 | ); 355 | } 356 | 357 | // First get available wikis 358 | const wikis = await getWikis(connection, { projectId: projectName }); 359 | 360 | // Skip if no wikis are available 361 | if (wikis.length === 0) { 362 | console.log('Skipping test: No wikis available in the project'); 363 | return; 364 | } 365 | 366 | // Use the first available wiki 367 | const wiki = wikis[0]; 368 | if (!wiki.name) { 369 | throw new Error('Wiki name is undefined'); 370 | } 371 | 372 | // Test basic page listing 373 | const firstResult = await listWikiPages({ 374 | organizationId, 375 | projectId: projectName, 376 | wikiId: wiki.name, 377 | }); 378 | 379 | expect(Array.isArray(firstResult)).toBe(true); 380 | 381 | // Test again for consistency 382 | const secondResult = await listWikiPages({ 383 | organizationId, 384 | projectId: projectName, 385 | wikiId: wiki.name, 386 | }); 387 | 388 | expect(Array.isArray(secondResult)).toBe(true); 389 | }); 390 | }); 391 | 392 | describe('Data Structure Validation', () => { 393 | test('should verify returned data structure matches WikiPageSummary interface', async () => { 394 | // Skip if integration tests are disabled or no connection available 395 | if (shouldSkipIntegrationTest()) { 396 | return; 397 | } 398 | 399 | // Get a real connection using environment variables 400 | const connection = await getTestConnection(); 401 | if (!connection) { 402 | throw new Error( 403 | 'Connection should be available when test is not skipped', 404 | ); 405 | } 406 | 407 | // First get available wikis 408 | const wikis = await getWikis(connection, { projectId: projectName }); 409 | 410 | // Skip if no wikis are available 411 | if (wikis.length === 0) { 412 | console.log('Skipping test: No wikis available in the project'); 413 | return; 414 | } 415 | 416 | // Use the first available wiki 417 | const wiki = wikis[0]; 418 | if (!wiki.name) { 419 | throw new Error('Wiki name is undefined'); 420 | } 421 | 422 | const result = await listWikiPages({ 423 | organizationId, 424 | projectId: projectName, 425 | wikiId: wiki.name, 426 | }); 427 | 428 | expect(Array.isArray(result)).toBe(true); 429 | 430 | // Validate each page in the result 431 | result.forEach((page: WikiPageSummary) => { 432 | // Required fields 433 | expect(page).toHaveProperty('id'); 434 | expect(page).toHaveProperty('path'); 435 | expect(page).toHaveProperty('url'); 436 | 437 | expect(typeof page.id).toBe('number'); 438 | expect(typeof page.path).toBe('string'); 439 | 440 | // Optional fields 441 | if (page.url !== undefined) { 442 | expect(typeof page.url).toBe('string'); 443 | } 444 | if (page.order !== undefined) { 445 | expect(typeof page.order).toBe('number'); 446 | } 447 | 448 | // Validate URL format (if present) 449 | if (page.url !== undefined) { 450 | expect(page.url).toMatch(/^https?:\/\//); 451 | } 452 | 453 | // Validate path format (should start with /) 454 | expect(page.path).toMatch(/^\//); 455 | }); 456 | }); 457 | }); 458 | 459 | describe('Performance and Pagination', () => { 460 | test('should handle large wiki structures efficiently', async () => { 461 | // Skip if integration tests are disabled or no connection available 462 | if (shouldSkipIntegrationTest()) { 463 | return; 464 | } 465 | 466 | // Get a real connection using environment variables 467 | const connection = await getTestConnection(); 468 | if (!connection) { 469 | throw new Error( 470 | 'Connection should be available when test is not skipped', 471 | ); 472 | } 473 | 474 | // First get available wikis 475 | const wikis = await getWikis(connection, { projectId: projectName }); 476 | 477 | // Skip if no wikis are available 478 | if (wikis.length === 0) { 479 | console.log('Skipping test: No wikis available in the project'); 480 | return; 481 | } 482 | 483 | // Use the first available wiki 484 | const wiki = wikis[0]; 485 | if (!wiki.name) { 486 | throw new Error('Wiki name is undefined'); 487 | } 488 | 489 | const startTime = Date.now(); 490 | 491 | const result = await listWikiPages({ 492 | organizationId, 493 | projectId: projectName, 494 | wikiId: wiki.name, 495 | }); 496 | 497 | const endTime = Date.now(); 498 | const duration = endTime - startTime; 499 | 500 | expect(Array.isArray(result)).toBe(true); 501 | 502 | // Performance check - should complete within reasonable time (30 seconds) 503 | expect(duration).toBeLessThan(30000); 504 | 505 | console.log(`Retrieved ${result.length} pages in ${duration}ms`); 506 | }); 507 | }); 508 | }); 509 | ```