This is page 4 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 -------------------------------------------------------------------------------- /docs/azure-identity-authentication.md: -------------------------------------------------------------------------------- ```markdown 1 | # Azure Identity Authentication for Azure DevOps MCP Server 2 | 3 | This guide explains how to use Azure Identity authentication with the Azure DevOps MCP Server. 4 | 5 | ## Overview 6 | 7 | Azure Identity authentication lets you use your existing Azure credentials to authenticate with Azure DevOps, instead of creating and managing Personal Access Tokens (PATs). This approach offers several benefits: 8 | 9 | - **Unified authentication**: Use the same credentials for Azure and Azure DevOps 10 | - **Enhanced security**: Support for managed identities and client certificates 11 | - **Flexible credential types**: Multiple options for different environments 12 | - **Automatic token management**: Handles token acquisition and renewal 13 | 14 | ## Credential Types 15 | 16 | The Azure DevOps MCP Server supports multiple credential types through the Azure Identity SDK: 17 | 18 | ### DefaultAzureCredential 19 | 20 | This credential type attempts multiple authentication methods in sequence until one succeeds: 21 | 22 | 1. Environment variables (EnvironmentCredential) 23 | 2. Managed Identity (ManagedIdentityCredential) 24 | 3. Azure CLI (AzureCliCredential) 25 | 4. Visual Studio Code (VisualStudioCodeCredential) 26 | 5. Azure PowerShell (AzurePowerShellCredential) 27 | 28 | It's a great option for applications that need to work across different environments without code changes. 29 | 30 | ### AzureCliCredential 31 | 32 | This credential type uses your Azure CLI login. It's perfect for local development when you're already using the Azure CLI. 33 | 34 | ## Configuration 35 | 36 | ### Environment Variables 37 | 38 | To use Azure Identity authentication, set the following environment variables: 39 | 40 | ```bash 41 | # Required 42 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 43 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 44 | 45 | # Optional 46 | AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name 47 | ``` 48 | 49 | For service principal authentication, add these environment variables: 50 | 51 | ```bash 52 | AZURE_TENANT_ID=your-tenant-id 53 | AZURE_CLIENT_ID=your-client-id 54 | AZURE_CLIENT_SECRET=your-client-secret 55 | ``` 56 | 57 | ### Use with Claude Desktop/Cursor AI 58 | 59 | Add the following to your configuration file: 60 | 61 | ```json 62 | { 63 | "mcpServers": { 64 | "azureDevOps": { 65 | "command": "npx", 66 | "args": ["-y", "@tiberriver256/mcp-server-azure-devops"], 67 | "env": { 68 | "AZURE_DEVOPS_ORG_URL": "https://dev.azure.com/your-organization", 69 | "AZURE_DEVOPS_AUTH_METHOD": "azure-identity", 70 | "AZURE_DEVOPS_DEFAULT_PROJECT": "your-project-name" 71 | } 72 | } 73 | } 74 | } 75 | ``` 76 | 77 | ## Authentication Methods 78 | 79 | ### Method 1: Using Azure CLI 80 | 81 | 1. Install the Azure CLI from [here](https://docs.microsoft.com/cli/azure/install-azure-cli) 82 | 2. Log in to Azure: 83 | ```bash 84 | az login 85 | ``` 86 | 3. Set up your environment variables: 87 | ```bash 88 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 89 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 90 | ``` 91 | 92 | ### Method 2: Using Service Principal 93 | 94 | 1. Create a service principal in Azure AD: 95 | ```bash 96 | az ad sp create-for-rbac --name "MyAzureDevOpsApp" 97 | ``` 98 | 2. Grant the service principal access to your Azure DevOps organization 99 | 3. Set up your environment variables: 100 | ```bash 101 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 102 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 103 | AZURE_TENANT_ID=your-tenant-id 104 | AZURE_CLIENT_ID=your-client-id 105 | AZURE_CLIENT_SECRET=your-client-secret 106 | ``` 107 | 108 | ### Method 3: Using Managed Identity (for Azure-hosted applications) 109 | 110 | 1. Enable managed identity for your Azure resource (VM, App Service, etc.) 111 | 2. Grant the managed identity access to your Azure DevOps organization 112 | 3. Set up your environment variables: 113 | ```bash 114 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization 115 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 116 | ``` 117 | 118 | ## Troubleshooting 119 | 120 | ### Common Issues 121 | 122 | #### Failed to acquire token 123 | 124 | ``` 125 | Error: Failed to authenticate with Azure Identity: CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token 126 | ``` 127 | 128 | **Possible solutions:** 129 | - Ensure you're logged in with `az login` 130 | - Check if your managed identity is correctly configured 131 | - Verify that service principal credentials are correct 132 | 133 | #### Permission issues 134 | 135 | ``` 136 | Error: Failed to authenticate with Azure Identity: AuthorizationFailed: The client does not have authorization to perform action 137 | ``` 138 | 139 | **Possible solutions:** 140 | - Ensure your identity has the necessary permissions in Azure DevOps 141 | - Check if you need to add your identity to specific Azure DevOps project(s) 142 | 143 | #### Network issues 144 | 145 | ``` 146 | Error: Failed to authenticate with Azure Identity: ClientAuthError: Interaction required 147 | ``` 148 | 149 | **Possible solutions:** 150 | - Check your network connectivity 151 | - Verify that your firewall allows connections to Azure services 152 | 153 | ## Best Practices 154 | 155 | 1. **Choose the right credential type for your environment**: 156 | - For local development: Azure CLI credential 157 | - For CI/CD pipelines: Service principal 158 | - For Azure-hosted applications: Managed identity 159 | 160 | 2. **Follow the principle of least privilege**: 161 | - Only grant the permissions needed for your use case 162 | - Regularly audit and review permissions 163 | 164 | 3. **Rotate credentials regularly**: 165 | - For service principals, rotate client secrets periodically 166 | - Use certificate-based authentication when possible for enhanced security 167 | 168 | ## Examples 169 | 170 | ### Basic configuration with Azure CLI 171 | 172 | ```bash 173 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 174 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 175 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 176 | ``` 177 | 178 | ### Service principal authentication 179 | 180 | ```bash 181 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany 182 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 183 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject 184 | AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 185 | AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111 186 | AZURE_CLIENT_SECRET=your-client-secret 187 | ``` 188 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { addPullRequestComment } from './feature'; 3 | import { listPullRequests } from '../list-pull-requests/feature'; 4 | import { 5 | getTestConnection, 6 | shouldSkipIntegrationTest, 7 | } from '@/shared/test/test-helpers'; 8 | 9 | describe('addPullRequestComment integration', () => { 10 | let connection: WebApi | null = null; 11 | let projectName: string; 12 | let repositoryName: string; 13 | let pullRequestId: number; 14 | 15 | // Generate unique identifiers using timestamp for comment content 16 | const timestamp = Date.now(); 17 | const randomSuffix = Math.floor(Math.random() * 1000); 18 | 19 | beforeAll(async () => { 20 | // Get a real connection using environment variables 21 | connection = await getTestConnection(); 22 | 23 | // Set up project and repository names from environment 24 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 25 | repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; 26 | 27 | // Skip setup if integration tests should be skipped 28 | if (shouldSkipIntegrationTest() || !connection) { 29 | return; 30 | } 31 | 32 | try { 33 | // Find an active pull request to use for testing 34 | const pullRequests = await listPullRequests( 35 | connection, 36 | projectName, 37 | repositoryName, 38 | { 39 | projectId: projectName, 40 | repositoryId: repositoryName, 41 | status: 'active', 42 | top: 1, 43 | }, 44 | ); 45 | 46 | if (!pullRequests || pullRequests.value.length === 0) { 47 | throw new Error('No active pull requests found for testing'); 48 | } 49 | 50 | pullRequestId = pullRequests.value[0].pullRequestId!; 51 | console.log(`Using existing pull request #${pullRequestId} for testing`); 52 | } catch (error) { 53 | console.error('Error in test setup:', error); 54 | throw error; 55 | } 56 | }); 57 | 58 | test('should add a new comment thread to pull request', async () => { 59 | // Skip if integration tests should be skipped 60 | if (shouldSkipIntegrationTest() || !connection) { 61 | console.log('Skipping test due to missing connection'); 62 | return; 63 | } 64 | 65 | // Skip if repository name is not defined 66 | if (!repositoryName) { 67 | console.log('Skipping test due to missing repository name'); 68 | return; 69 | } 70 | 71 | const commentContent = `Test comment ${timestamp}-${randomSuffix}`; 72 | 73 | const result = await addPullRequestComment( 74 | connection, 75 | projectName, 76 | repositoryName, 77 | pullRequestId, 78 | { 79 | projectId: projectName, 80 | repositoryId: repositoryName, 81 | pullRequestId, 82 | content: commentContent, 83 | status: 'active', 84 | }, 85 | ); 86 | 87 | // Verify the comment was created 88 | expect(result.comment).toBeDefined(); 89 | expect(result.comment.content).toBe(commentContent); 90 | expect(result.thread).toBeDefined(); 91 | expect(result.thread!.status).toBe('active'); // Transformed to string 92 | }, 30000); // 30 second timeout for integration test 93 | 94 | test('should add a file comment to pull request', async () => { 95 | // Skip if integration tests should be skipped 96 | if (shouldSkipIntegrationTest() || !connection) { 97 | console.log('Skipping test due to missing connection'); 98 | return; 99 | } 100 | 101 | // Skip if repository name is not defined 102 | if (!repositoryName) { 103 | console.log('Skipping test due to missing repository name'); 104 | return; 105 | } 106 | 107 | const commentContent = `File comment ${timestamp}-${randomSuffix}`; 108 | const filePath = '/README.md'; // Assuming README.md exists in the repo 109 | const lineNumber = 1; 110 | 111 | const result = await addPullRequestComment( 112 | connection, 113 | projectName, 114 | repositoryName, 115 | pullRequestId, 116 | { 117 | projectId: projectName, 118 | repositoryId: repositoryName, 119 | pullRequestId, 120 | content: commentContent, 121 | filePath, 122 | lineNumber, 123 | status: 'active', 124 | }, 125 | ); 126 | 127 | // Verify the file comment was created 128 | expect(result.comment).toBeDefined(); 129 | expect(result.comment.content).toBe(commentContent); 130 | expect(result.thread).toBeDefined(); 131 | expect(result.thread!.threadContext).toBeDefined(); 132 | expect(result.thread!.threadContext!.filePath).toBe(filePath); 133 | expect(result.thread!.threadContext!.rightFileStart!.line).toBe(lineNumber); 134 | }, 30000); // 30 second timeout for integration test 135 | 136 | test('should add a reply to an existing comment thread', async () => { 137 | // Skip if integration tests should be skipped 138 | if (shouldSkipIntegrationTest() || !connection) { 139 | console.log('Skipping test due to missing connection'); 140 | return; 141 | } 142 | 143 | // Skip if repository name is not defined 144 | if (!repositoryName) { 145 | console.log('Skipping test due to missing repository name'); 146 | return; 147 | } 148 | 149 | // First create a thread 150 | const initialComment = await addPullRequestComment( 151 | connection, 152 | projectName, 153 | repositoryName, 154 | pullRequestId, 155 | { 156 | projectId: projectName, 157 | repositoryId: repositoryName, 158 | pullRequestId, 159 | content: `Initial comment ${timestamp}-${randomSuffix}`, 160 | status: 'active', 161 | }, 162 | ); 163 | 164 | const threadId = initialComment.thread!.id!; 165 | const replyContent = `Reply comment ${timestamp}-${randomSuffix}`; 166 | 167 | // Add a reply to the thread 168 | const result = await addPullRequestComment( 169 | connection, 170 | projectName, 171 | repositoryName, 172 | pullRequestId, 173 | { 174 | projectId: projectName, 175 | repositoryId: repositoryName, 176 | pullRequestId, 177 | content: replyContent, 178 | threadId, 179 | }, 180 | ); 181 | 182 | // Verify the reply was created 183 | expect(result.comment).toBeDefined(); 184 | expect(result.comment.content).toBe(replyContent); 185 | expect(result.thread).toBeUndefined(); // No thread returned for replies 186 | }, 30000); // 30 second timeout for integration test 187 | }); 188 | ``` -------------------------------------------------------------------------------- /src/features/work-items/create-work-item/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { createWorkItem } from './feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '@/shared/test/test-helpers'; 7 | import { CreateWorkItemOptions } from '../types'; 8 | 9 | describe('createWorkItem integration', () => { 10 | let connection: WebApi | null = null; 11 | 12 | beforeAll(async () => { 13 | // Get a real connection using environment variables 14 | connection = await getTestConnection(); 15 | }); 16 | 17 | test('should create a new work item in Azure DevOps', async () => { 18 | // Skip if no connection is available 19 | if (shouldSkipIntegrationTest()) { 20 | return; 21 | } 22 | 23 | // This connection must be available if we didn't skip 24 | if (!connection) { 25 | throw new Error( 26 | 'Connection should be available when test is not skipped', 27 | ); 28 | } 29 | 30 | // Create a unique title using timestamp to avoid conflicts 31 | const uniqueTitle = `Test Work Item ${new Date().toISOString()}`; 32 | 33 | // For a true integration test, use a real project 34 | const projectName = 35 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 36 | const workItemType = 'Task'; // Assumes 'Task' type exists in the project 37 | 38 | const options: CreateWorkItemOptions = { 39 | title: uniqueTitle, 40 | description: 'This is a test work item created by an integration test', 41 | priority: 2, 42 | }; 43 | 44 | // Act - make an actual API call to Azure DevOps 45 | const result = await createWorkItem( 46 | connection, 47 | projectName, 48 | workItemType, 49 | options, 50 | ); 51 | 52 | // Assert on the actual response 53 | expect(result).toBeDefined(); 54 | expect(result.id).toBeDefined(); 55 | 56 | // Verify fields match what we set 57 | expect(result.fields).toBeDefined(); 58 | if (result.fields) { 59 | expect(result.fields['System.Title']).toBe(uniqueTitle); 60 | expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(2); 61 | } 62 | }); 63 | 64 | test('should create a work item with additional fields', async () => { 65 | // Skip if no connection is available 66 | if (shouldSkipIntegrationTest()) { 67 | return; 68 | } 69 | 70 | // This connection must be available if we didn't skip 71 | if (!connection) { 72 | throw new Error( 73 | 'Connection should be available when test is not skipped', 74 | ); 75 | } 76 | 77 | // Create a unique title using timestamp to avoid conflicts 78 | const uniqueTitle = `Test Work Item with Fields ${new Date().toISOString()}`; 79 | 80 | // For a true integration test, use a real project 81 | const projectName = 82 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 83 | const workItemType = 'Task'; 84 | 85 | const options: CreateWorkItemOptions = { 86 | title: uniqueTitle, 87 | description: 'This is a test work item with additional fields', 88 | priority: 1, 89 | additionalFields: { 90 | 'System.Tags': 'Integration Test,Automated', 91 | }, 92 | }; 93 | 94 | // Act - make an actual API call to Azure DevOps 95 | const result = await createWorkItem( 96 | connection, 97 | projectName, 98 | workItemType, 99 | options, 100 | ); 101 | 102 | // Assert on the actual response 103 | expect(result).toBeDefined(); 104 | expect(result.id).toBeDefined(); 105 | 106 | // Verify fields match what we set 107 | expect(result.fields).toBeDefined(); 108 | if (result.fields) { 109 | expect(result.fields['System.Title']).toBe(uniqueTitle); 110 | expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(1); 111 | // Just check that tags contain both values, order may vary 112 | expect(result.fields['System.Tags']).toContain('Integration Test'); 113 | expect(result.fields['System.Tags']).toContain('Automated'); 114 | } 115 | }); 116 | 117 | test('should create a child work item with parent-child relationship', async () => { 118 | // Skip if no connection is available 119 | if (shouldSkipIntegrationTest()) { 120 | return; 121 | } 122 | 123 | // This connection must be available if we didn't skip 124 | if (!connection) { 125 | throw new Error( 126 | 'Connection should be available when test is not skipped', 127 | ); 128 | } 129 | 130 | // For a true integration test, use a real project 131 | const projectName = 132 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 133 | 134 | // First, create a parent work item (User Story) 135 | const parentTitle = `Parent Story ${new Date().toISOString()}`; 136 | const parentOptions: CreateWorkItemOptions = { 137 | title: parentTitle, 138 | description: 'This is a parent user story', 139 | }; 140 | 141 | const parentResult = await createWorkItem( 142 | connection, 143 | projectName, 144 | 'User Story', // Assuming User Story type exists 145 | parentOptions, 146 | ); 147 | 148 | expect(parentResult).toBeDefined(); 149 | expect(parentResult.id).toBeDefined(); 150 | const parentId = parentResult.id; 151 | 152 | // Now create a child work item (Task) with a link to the parent 153 | const childTitle = `Child Task ${new Date().toISOString()}`; 154 | const childOptions: CreateWorkItemOptions = { 155 | title: childTitle, 156 | description: 'This is a child task of a user story', 157 | parentId: parentId, // Reference to parent work item 158 | }; 159 | 160 | const childResult = await createWorkItem( 161 | connection, 162 | projectName, 163 | 'Task', 164 | childOptions, 165 | ); 166 | 167 | // Assert the child work item was created 168 | expect(childResult).toBeDefined(); 169 | expect(childResult.id).toBeDefined(); 170 | 171 | // Now verify the parent-child relationship 172 | // We would need to fetch the relations, but for now we'll just assert 173 | // that the response indicates a relationship was created 174 | expect(childResult.relations).toBeDefined(); 175 | 176 | // Check that at least one relation exists that points to our parent 177 | const parentRelation = childResult.relations?.find( 178 | (relation) => 179 | relation.rel === 'System.LinkTypes.Hierarchy-Reverse' && 180 | relation.url && 181 | relation.url.includes(`/${parentId}`), 182 | ); 183 | expect(parentRelation).toBeDefined(); 184 | }); 185 | }); 186 | ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { 3 | AzureDevOpsError, 4 | AzureDevOpsResourceNotFoundError, 5 | AzureDevOpsValidationError, 6 | AzureDevOpsPermissionError, 7 | } from '../../../shared/errors'; 8 | import { createWiki } from './feature'; 9 | import { WikiType } from './schema'; 10 | import { getWikiClient } from '../../../clients/azure-devops'; 11 | 12 | // Mock the WikiClient 13 | jest.mock('../../../clients/azure-devops'); 14 | 15 | describe('createWiki unit', () => { 16 | // Mock WikiClient 17 | const mockWikiClient = { 18 | createWiki: jest.fn(), 19 | }; 20 | 21 | // Mock WebApi connection (kept for backward compatibility) 22 | const mockConnection = {} as WebApi; 23 | 24 | beforeEach(() => { 25 | // Clear mock calls between tests 26 | jest.clearAllMocks(); 27 | // Setup mock response for getWikiClient 28 | (getWikiClient as jest.Mock).mockResolvedValue(mockWikiClient); 29 | }); 30 | 31 | test('should create a project wiki', async () => { 32 | // Mock data 33 | const mockWiki = { 34 | id: 'wiki1', 35 | name: 'Project Wiki', 36 | projectId: 'project1', 37 | remoteUrl: 'https://example.com/wiki1', 38 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1', 39 | type: 'projectWiki', 40 | repositoryId: 'repo1', 41 | mappedPath: '/', 42 | }; 43 | 44 | // Setup mock response 45 | mockWikiClient.createWiki.mockResolvedValue(mockWiki); 46 | 47 | // Call the function 48 | const result = await createWiki(mockConnection, { 49 | name: 'Project Wiki', 50 | projectId: 'project1', 51 | }); 52 | 53 | // Assertions 54 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 55 | expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', { 56 | name: 'Project Wiki', 57 | projectId: 'project1', 58 | type: WikiType.ProjectWiki, 59 | }); 60 | expect(result).toEqual(mockWiki); 61 | }); 62 | 63 | test('should create a code wiki', async () => { 64 | // Mock data 65 | const mockWiki = { 66 | id: 'wiki2', 67 | name: 'Code Wiki', 68 | projectId: 'project1', 69 | repositoryId: 'repo1', 70 | mappedPath: '/docs', 71 | remoteUrl: 'https://example.com/wiki2', 72 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki2', 73 | type: 'codeWiki', 74 | }; 75 | 76 | // Setup mock response 77 | mockWikiClient.createWiki.mockResolvedValue(mockWiki); 78 | 79 | // Call the function 80 | const result = await createWiki(mockConnection, { 81 | name: 'Code Wiki', 82 | projectId: 'project1', 83 | type: WikiType.CodeWiki, 84 | repositoryId: 'repo1', 85 | mappedPath: '/docs', 86 | }); 87 | 88 | // Assertions 89 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 90 | expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', { 91 | name: 'Code Wiki', 92 | projectId: 'project1', 93 | type: WikiType.CodeWiki, 94 | repositoryId: 'repo1', 95 | mappedPath: '/docs', 96 | version: { 97 | version: 'main', 98 | versionType: 'branch' as const, 99 | }, 100 | }); 101 | expect(result).toEqual(mockWiki); 102 | }); 103 | 104 | test('should throw validation error when repository ID is missing for code wiki', async () => { 105 | // Call the function and expect it to throw 106 | await expect( 107 | createWiki(mockConnection, { 108 | name: 'Code Wiki', 109 | projectId: 'project1', 110 | type: WikiType.CodeWiki, 111 | // repositoryId is missing 112 | }), 113 | ).rejects.toThrow(AzureDevOpsValidationError); 114 | 115 | // Assertions 116 | expect(getWikiClient).not.toHaveBeenCalled(); 117 | expect(mockWikiClient.createWiki).not.toHaveBeenCalled(); 118 | }); 119 | 120 | test('should handle project not found error', async () => { 121 | // Setup mock to throw an error 122 | mockWikiClient.createWiki.mockRejectedValue( 123 | new AzureDevOpsResourceNotFoundError('Project not found'), 124 | ); 125 | 126 | // Call the function and expect it to throw 127 | await expect( 128 | createWiki(mockConnection, { 129 | name: 'Project Wiki', 130 | projectId: 'nonExistentProject', 131 | }), 132 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 133 | 134 | // Assertions 135 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 136 | expect(mockWikiClient.createWiki).toHaveBeenCalled(); 137 | }); 138 | 139 | test('should handle repository not found error', async () => { 140 | // Setup mock to throw an error 141 | mockWikiClient.createWiki.mockRejectedValue( 142 | new AzureDevOpsResourceNotFoundError('Repository not found'), 143 | ); 144 | 145 | // Call the function and expect it to throw 146 | await expect( 147 | createWiki(mockConnection, { 148 | name: 'Code Wiki', 149 | projectId: 'project1', 150 | type: WikiType.CodeWiki, 151 | repositoryId: 'nonExistentRepo', 152 | }), 153 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 154 | 155 | // Assertions 156 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 157 | expect(mockWikiClient.createWiki).toHaveBeenCalled(); 158 | }); 159 | 160 | test('should handle permission error', async () => { 161 | // Setup mock to throw an error 162 | mockWikiClient.createWiki.mockRejectedValue( 163 | new AzureDevOpsPermissionError('You do not have permission'), 164 | ); 165 | 166 | // Call the function and expect it to throw 167 | await expect( 168 | createWiki(mockConnection, { 169 | name: 'Project Wiki', 170 | projectId: 'project1', 171 | }), 172 | ).rejects.toThrow(AzureDevOpsPermissionError); 173 | 174 | // Assertions 175 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 176 | expect(mockWikiClient.createWiki).toHaveBeenCalled(); 177 | }); 178 | 179 | test('should handle generic errors', async () => { 180 | // Setup mock to throw an error 181 | mockWikiClient.createWiki.mockRejectedValue(new Error('Unknown error')); 182 | 183 | // Call the function and expect it to throw 184 | await expect( 185 | createWiki(mockConnection, { 186 | name: 'Project Wiki', 187 | projectId: 'project1', 188 | }), 189 | ).rejects.toThrow(AzureDevOpsError); 190 | 191 | // Assertions 192 | expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined }); 193 | expect(mockWikiClient.createWiki).toHaveBeenCalled(); 194 | }); 195 | }); 196 | ``` -------------------------------------------------------------------------------- /src/features/search/search-wiki/feature.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import axios from 'axios'; 3 | import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; 4 | import { 5 | AzureDevOpsError, 6 | AzureDevOpsResourceNotFoundError, 7 | AzureDevOpsValidationError, 8 | AzureDevOpsPermissionError, 9 | } from '../../../shared/errors'; 10 | import { 11 | SearchWikiOptions, 12 | WikiSearchRequest, 13 | WikiSearchResponse, 14 | } from '../types'; 15 | 16 | /** 17 | * Search for wiki pages in Azure DevOps projects 18 | * 19 | * @param connection The Azure DevOps WebApi connection 20 | * @param options Parameters for searching wiki pages 21 | * @returns Search results for wiki pages 22 | */ 23 | export async function searchWiki( 24 | connection: WebApi, 25 | options: SearchWikiOptions, 26 | ): Promise<WikiSearchResponse> { 27 | try { 28 | // Prepare the search request 29 | const searchRequest: WikiSearchRequest = { 30 | searchText: options.searchText, 31 | $skip: options.skip, 32 | $top: options.top, 33 | filters: options.projectId 34 | ? { 35 | Project: [options.projectId], 36 | } 37 | : {}, 38 | includeFacets: options.includeFacets, 39 | }; 40 | 41 | // Add custom filters if provided 42 | if ( 43 | options.filters && 44 | options.filters.Project && 45 | options.filters.Project.length > 0 46 | ) { 47 | if (!searchRequest.filters) { 48 | searchRequest.filters = {}; 49 | } 50 | 51 | if (!searchRequest.filters.Project) { 52 | searchRequest.filters.Project = []; 53 | } 54 | 55 | searchRequest.filters.Project = [ 56 | ...(searchRequest.filters.Project || []), 57 | ...options.filters.Project, 58 | ]; 59 | } 60 | 61 | // Get the authorization header from the connection 62 | const authHeader = await getAuthorizationHeader(); 63 | 64 | // Extract organization and project from the connection URL 65 | const { organization, project } = extractOrgAndProject( 66 | connection, 67 | options.projectId, 68 | ); 69 | 70 | // Make the search API request 71 | // If projectId is provided, include it in the URL, otherwise perform organization-wide search 72 | const searchUrl = options.projectId 73 | ? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/wikisearchresults?api-version=7.1` 74 | : `https://almsearch.dev.azure.com/${organization}/_apis/search/wikisearchresults?api-version=7.1`; 75 | 76 | const searchResponse = await axios.post<WikiSearchResponse>( 77 | searchUrl, 78 | searchRequest, 79 | { 80 | headers: { 81 | Authorization: authHeader, 82 | 'Content-Type': 'application/json', 83 | }, 84 | }, 85 | ); 86 | 87 | return searchResponse.data; 88 | } catch (error) { 89 | // If it's already an AzureDevOpsError, rethrow it 90 | if (error instanceof AzureDevOpsError) { 91 | throw error; 92 | } 93 | 94 | // Handle axios errors 95 | if (axios.isAxiosError(error)) { 96 | const status = error.response?.status; 97 | const message = error.response?.data?.message || error.message; 98 | 99 | if (status === 404) { 100 | throw new AzureDevOpsResourceNotFoundError( 101 | `Resource not found: ${message}`, 102 | ); 103 | } else if (status === 400) { 104 | throw new AzureDevOpsValidationError( 105 | `Invalid request: ${message}`, 106 | error.response?.data, 107 | ); 108 | } else if (status === 401 || status === 403) { 109 | throw new AzureDevOpsPermissionError(`Permission denied: ${message}`); 110 | } else { 111 | // For other axios errors, wrap in a generic AzureDevOpsError 112 | throw new AzureDevOpsError(`Azure DevOps API error: ${message}`); 113 | } 114 | 115 | // This return is never reached but helps TypeScript understand the control flow 116 | return null as never; 117 | } 118 | 119 | // Otherwise, wrap it in a generic error 120 | throw new AzureDevOpsError( 121 | `Failed to search wiki: ${error instanceof Error ? error.message : String(error)}`, 122 | ); 123 | } 124 | } 125 | 126 | /** 127 | * Extract organization and project from the connection URL 128 | * 129 | * @param connection The Azure DevOps WebApi connection 130 | * @param projectId The project ID or name (optional) 131 | * @returns The organization and project 132 | */ 133 | function extractOrgAndProject( 134 | connection: WebApi, 135 | projectId?: string, 136 | ): { organization: string; project: string } { 137 | // Extract organization from the connection URL 138 | const url = connection.serverUrl; 139 | const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); 140 | const organization = match ? match[1] : ''; 141 | 142 | if (!organization) { 143 | throw new AzureDevOpsValidationError( 144 | 'Could not extract organization from connection URL', 145 | ); 146 | } 147 | 148 | return { 149 | organization, 150 | project: projectId || '', 151 | }; 152 | } 153 | 154 | /** 155 | * Get the authorization header from the connection 156 | * 157 | * @returns The authorization header 158 | */ 159 | async function getAuthorizationHeader(): Promise<string> { 160 | try { 161 | // For PAT authentication, we can construct the header directly 162 | if ( 163 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && 164 | process.env.AZURE_DEVOPS_PAT 165 | ) { 166 | // For PAT auth, we can construct the Basic auth header directly 167 | const token = process.env.AZURE_DEVOPS_PAT; 168 | const base64Token = Buffer.from(`:${token}`).toString('base64'); 169 | return `Basic ${base64Token}`; 170 | } 171 | 172 | // For Azure Identity / Azure CLI auth, we need to get a token 173 | // using the Azure DevOps resource ID 174 | // Choose the appropriate credential based on auth method 175 | const credential = 176 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' 177 | ? new AzureCliCredential() 178 | : new DefaultAzureCredential(); 179 | 180 | // Azure DevOps resource ID for token acquisition 181 | const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; 182 | 183 | // Get token for Azure DevOps 184 | const token = await credential.getToken( 185 | `${AZURE_DEVOPS_RESOURCE_ID}/.default`, 186 | ); 187 | 188 | if (!token || !token.token) { 189 | throw new Error('Failed to acquire token for Azure DevOps'); 190 | } 191 | 192 | return `Bearer ${token.token}`; 193 | } catch (error) { 194 | throw new AzureDevOpsValidationError( 195 | `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, 196 | ); 197 | } 198 | } 199 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-file-content/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { IGitApi } from 'azure-devops-node-api/GitApi'; 3 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; 4 | import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; 5 | import { getFileContent } from './feature'; 6 | import { Readable } from 'stream'; 7 | 8 | describe('getFileContent', () => { 9 | let mockConnection: WebApi; 10 | let mockGitApi: IGitApi; 11 | const mockRepositoryId = 'test-repo'; 12 | const mockProjectId = 'test-project'; 13 | const mockFilePath = '/path/to/file.txt'; 14 | const mockFileContent = 'Test file content'; 15 | const mockItem = { 16 | objectId: '123456', 17 | path: mockFilePath, 18 | url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo/items/path/to/file.txt', 19 | gitObjectType: 'blob', 20 | }; 21 | 22 | // Helper function to create a readable stream from a string 23 | function createReadableStream(content: string): Readable { 24 | const stream = new Readable(); 25 | stream.push(content); 26 | stream.push(null); // Signals the end of the stream 27 | return stream; 28 | } 29 | 30 | beforeEach(() => { 31 | mockGitApi = { 32 | getItemContent: jest 33 | .fn() 34 | .mockResolvedValue(createReadableStream(mockFileContent)), 35 | getItems: jest.fn().mockResolvedValue([mockItem]), 36 | } as unknown as IGitApi; 37 | 38 | mockConnection = { 39 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 40 | } as unknown as WebApi; 41 | }); 42 | 43 | it('should get file content for a file in the default branch', async () => { 44 | const result = await getFileContent( 45 | mockConnection, 46 | mockProjectId, 47 | mockRepositoryId, 48 | mockFilePath, 49 | ); 50 | 51 | expect(mockConnection.getGitApi).toHaveBeenCalled(); 52 | expect(mockGitApi.getItems).toHaveBeenCalledWith( 53 | mockRepositoryId, 54 | mockProjectId, 55 | mockFilePath, 56 | expect.any(Number), // VersionControlRecursionType.OneLevel 57 | undefined, 58 | undefined, 59 | undefined, 60 | undefined, 61 | undefined, 62 | ); 63 | 64 | expect(mockGitApi.getItemContent).toHaveBeenCalledWith( 65 | mockRepositoryId, 66 | mockFilePath, 67 | mockProjectId, 68 | undefined, 69 | undefined, 70 | undefined, 71 | undefined, 72 | false, 73 | undefined, 74 | true, 75 | ); 76 | 77 | expect(result).toEqual({ 78 | content: mockFileContent, 79 | isDirectory: false, 80 | }); 81 | }); 82 | 83 | it('should get file content for a file in a specific branch', async () => { 84 | const branchName = 'test-branch'; 85 | const versionDescriptor = { 86 | versionType: GitVersionType.Branch, 87 | version: branchName, 88 | versionOptions: undefined, 89 | }; 90 | 91 | const result = await getFileContent( 92 | mockConnection, 93 | mockProjectId, 94 | mockRepositoryId, 95 | mockFilePath, 96 | { 97 | versionType: GitVersionType.Branch, 98 | version: branchName, 99 | }, 100 | ); 101 | 102 | expect(mockConnection.getGitApi).toHaveBeenCalled(); 103 | expect(mockGitApi.getItems).toHaveBeenCalledWith( 104 | mockRepositoryId, 105 | mockProjectId, 106 | mockFilePath, 107 | expect.any(Number), // VersionControlRecursionType.OneLevel 108 | undefined, 109 | undefined, 110 | undefined, 111 | undefined, 112 | versionDescriptor, 113 | ); 114 | 115 | expect(mockGitApi.getItemContent).toHaveBeenCalledWith( 116 | mockRepositoryId, 117 | mockFilePath, 118 | mockProjectId, 119 | undefined, 120 | undefined, 121 | undefined, 122 | undefined, 123 | false, 124 | versionDescriptor, 125 | true, 126 | ); 127 | 128 | expect(result).toEqual({ 129 | content: mockFileContent, 130 | isDirectory: false, 131 | }); 132 | }); 133 | 134 | it('should throw an error if the file is not found', async () => { 135 | // Mock getItems to throw an error 136 | mockGitApi.getItems = jest 137 | .fn() 138 | .mockRejectedValue(new Error('Item not found')); 139 | 140 | // Mock getItemContent to throw a specific error indicating not found 141 | mockGitApi.getItemContent = jest 142 | .fn() 143 | .mockRejectedValue(new Error('Item not found')); 144 | 145 | await expect( 146 | getFileContent( 147 | mockConnection, 148 | mockProjectId, 149 | mockRepositoryId, 150 | '/invalid/path', 151 | ), 152 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError); 153 | }); 154 | 155 | it('should get directory content if the path is a directory', async () => { 156 | const dirPath = '/path/to/dir'; 157 | const mockDirectoryItems = [ 158 | { 159 | path: `${dirPath}/file1.txt`, 160 | gitObjectType: 'blob', 161 | isFolder: false, 162 | }, 163 | { 164 | path: `${dirPath}/file2.md`, 165 | gitObjectType: 'blob', 166 | isFolder: false, 167 | }, 168 | { 169 | path: `${dirPath}/subdir`, 170 | gitObjectType: 'tree', 171 | isFolder: true, 172 | }, 173 | ]; 174 | 175 | // Mock getItems to return multiple items, indicating a directory 176 | mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems); 177 | 178 | const result = await getFileContent( 179 | mockConnection, 180 | mockProjectId, 181 | mockRepositoryId, 182 | dirPath, 183 | ); 184 | 185 | expect(mockConnection.getGitApi).toHaveBeenCalled(); 186 | expect(mockGitApi.getItems).toHaveBeenCalledWith( 187 | mockRepositoryId, 188 | mockProjectId, 189 | dirPath, 190 | expect.any(Number), // VersionControlRecursionType.OneLevel 191 | undefined, 192 | undefined, 193 | undefined, 194 | undefined, 195 | undefined, 196 | ); 197 | 198 | // Should not attempt to get file content for a directory 199 | expect(mockGitApi.getItemContent).not.toHaveBeenCalled(); 200 | 201 | expect(result).toEqual({ 202 | content: JSON.stringify(mockDirectoryItems, null, 2), 203 | isDirectory: true, 204 | }); 205 | }); 206 | 207 | it('should handle a directory path with trailing slash', async () => { 208 | const dirPath = '/path/to/dir/'; 209 | const mockDirectoryItems = [ 210 | { 211 | path: `${dirPath}file1.txt`, 212 | gitObjectType: 'blob', 213 | isFolder: false, 214 | }, 215 | ]; 216 | 217 | // Even with one item, it should be treated as a directory due to trailing slash 218 | mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems); 219 | 220 | const result = await getFileContent( 221 | mockConnection, 222 | mockProjectId, 223 | mockRepositoryId, 224 | dirPath, 225 | ); 226 | 227 | expect(result.isDirectory).toBe(true); 228 | expect(result.content).toBe(JSON.stringify(mockDirectoryItems, null, 2)); 229 | }); 230 | }); 231 | ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { searchWorkItems } from './feature'; 3 | import { getConnection } from '../../../server'; 4 | import { AzureDevOpsConfig } from '../../../shared/types'; 5 | import { AuthenticationMethod } from '../../../shared/auth'; 6 | 7 | // Skip tests if no PAT is available 8 | const hasPat = process.env.AZURE_DEVOPS_PAT && process.env.AZURE_DEVOPS_ORG_URL; 9 | const describeOrSkip = hasPat ? describe : describe.skip; 10 | 11 | describeOrSkip('searchWorkItems (Integration)', () => { 12 | let connection: WebApi; 13 | let config: AzureDevOpsConfig; 14 | let projectId: string; 15 | 16 | beforeAll(async () => { 17 | // Set up the connection 18 | config = { 19 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', 20 | authMethod: AuthenticationMethod.PersonalAccessToken, 21 | personalAccessToken: process.env.AZURE_DEVOPS_PAT || '', 22 | defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '', 23 | }; 24 | 25 | connection = await getConnection(config); 26 | projectId = config.defaultProject || ''; 27 | 28 | // Skip tests if no default project is set 29 | if (!projectId) { 30 | console.warn('Skipping integration tests: No default project set'); 31 | } 32 | }, 30000); 33 | 34 | it('should search for work items', async () => { 35 | // Skip test if no default project 36 | if (!projectId) { 37 | return; 38 | } 39 | 40 | // Act 41 | const result = await searchWorkItems(connection, { 42 | searchText: 'test', 43 | projectId, 44 | top: 10, 45 | includeFacets: true, 46 | }); 47 | 48 | // Assert 49 | expect(result).toBeDefined(); 50 | expect(typeof result.count).toBe('number'); 51 | expect(Array.isArray(result.results)).toBe(true); 52 | 53 | // If there are results, verify their structure 54 | if (result.results.length > 0) { 55 | const firstResult = result.results[0]; 56 | expect(firstResult.project).toBeDefined(); 57 | expect(firstResult.fields).toBeDefined(); 58 | expect(firstResult.fields['system.id']).toBeDefined(); 59 | expect(firstResult.fields['system.title']).toBeDefined(); 60 | expect(firstResult.hits).toBeDefined(); 61 | expect(firstResult.url).toBeDefined(); 62 | } 63 | 64 | // If facets were requested, verify their structure 65 | if (result.facets) { 66 | expect(result.facets).toBeDefined(); 67 | } 68 | }, 30000); 69 | 70 | it('should filter work items by type', async () => { 71 | // Skip test if no default project 72 | if (!projectId) { 73 | return; 74 | } 75 | 76 | // Act 77 | const result = await searchWorkItems(connection, { 78 | searchText: 'test', 79 | projectId, 80 | filters: { 81 | 'System.WorkItemType': ['Bug'], 82 | }, 83 | top: 10, 84 | }); 85 | 86 | // Assert 87 | expect(result).toBeDefined(); 88 | 89 | // If there are results, verify they are all bugs 90 | if (result.results.length > 0) { 91 | result.results.forEach((item) => { 92 | expect(item.fields['system.workitemtype'].toLowerCase()).toBe('bug'); 93 | }); 94 | } 95 | }, 30000); 96 | 97 | it('should support pagination', async () => { 98 | // Skip test if no default project 99 | if (!projectId) { 100 | return; 101 | } 102 | 103 | // Act - Get first page 104 | const firstPage = await searchWorkItems(connection, { 105 | searchText: 'test', 106 | projectId, 107 | top: 5, 108 | skip: 0, 109 | }); 110 | 111 | // If there are enough results, test pagination 112 | if (firstPage.count > 5) { 113 | // Act - Get second page 114 | const secondPage = await searchWorkItems(connection, { 115 | searchText: 'test', 116 | projectId, 117 | top: 5, 118 | skip: 5, 119 | }); 120 | 121 | // Assert 122 | expect(secondPage).toBeDefined(); 123 | expect(secondPage.results).toBeDefined(); 124 | 125 | // Verify the pages have different items 126 | if (firstPage.results.length > 0 && secondPage.results.length > 0) { 127 | const firstPageIds = firstPage.results.map( 128 | (r) => r.fields['system.id'], 129 | ); 130 | const secondPageIds = secondPage.results.map( 131 | (r) => r.fields['system.id'], 132 | ); 133 | 134 | // Check that the pages don't have overlapping IDs 135 | const overlap = firstPageIds.filter((id) => secondPageIds.includes(id)); 136 | expect(overlap.length).toBe(0); 137 | } 138 | } 139 | }, 30000); 140 | 141 | it('should support sorting', async () => { 142 | // Skip test if no default project 143 | if (!projectId) { 144 | return; 145 | } 146 | 147 | // Act - Get results sorted by creation date (newest first) 148 | const result = await searchWorkItems(connection, { 149 | searchText: 'test', 150 | projectId, 151 | orderBy: [{ field: 'System.CreatedDate', sortOrder: 'DESC' }], 152 | top: 10, 153 | }); 154 | 155 | // Assert 156 | expect(result).toBeDefined(); 157 | 158 | // If there are multiple results, verify they are sorted 159 | if (result.results.length > 1) { 160 | const dates = result.results 161 | .filter((r) => r.fields['system.createddate'] !== undefined) 162 | .map((r) => 163 | new Date(r.fields['system.createddate'] as string).getTime(), 164 | ); 165 | 166 | // Check that dates are in descending order 167 | for (let i = 0; i < dates.length - 1; i++) { 168 | expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]); 169 | } 170 | } 171 | }, 30000); 172 | 173 | // Add a test to verify Azure Identity authentication if configured 174 | if ( 175 | process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-identity' 176 | ) { 177 | test('should search work items using Azure Identity authentication', async () => { 178 | // Skip if required environment variables are missing 179 | if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.TEST_PROJECT_ID) { 180 | console.log('Skipping test: required environment variables missing'); 181 | return; 182 | } 183 | 184 | // Create a config with Azure Identity authentication 185 | const testConfig: AzureDevOpsConfig = { 186 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL, 187 | authMethod: AuthenticationMethod.AzureIdentity, 188 | defaultProject: process.env.TEST_PROJECT_ID, 189 | }; 190 | 191 | // Create the connection using the config 192 | const connection = await getConnection(testConfig); 193 | 194 | // Search work items 195 | const result = await searchWorkItems(connection, { 196 | projectId: process.env.TEST_PROJECT_ID, 197 | searchText: 'test', 198 | }); 199 | 200 | // Check that the response is properly formatted 201 | expect(result).toBeDefined(); 202 | expect(result.count).toBeDefined(); 203 | expect(Array.isArray(result.results)).toBe(true); 204 | }); 205 | } 206 | }); 207 | ``` -------------------------------------------------------------------------------- /docs/tools/pipelines.md: -------------------------------------------------------------------------------- ```markdown 1 | # Pipeline Tools 2 | 3 | This document describes the tools available for working with Azure DevOps pipelines. 4 | 5 | ## Table of Contents 6 | 7 | - [`list_pipelines`](#list_pipelines) - List pipelines in a project 8 | - [`get_pipeline`](#get_pipeline) - Get details of a specific pipeline 9 | - [`trigger_pipeline`](#trigger_pipeline) - Trigger a pipeline run 10 | 11 | ## list_pipelines 12 | 13 | Lists pipelines in a project. 14 | 15 | ### Parameters 16 | 17 | | Parameter | Type | Required | Description | 18 | | ----------- | ------ | -------- | --------------------------------------------------------- | 19 | | `projectId` | string | No | The ID or name of the project (Default: from environment) | 20 | | `orderBy` | string | No | Order by field and direction (e.g., "createdDate desc") | 21 | | `top` | number | No | Maximum number of pipelines to return | 22 | 23 | ### Response 24 | 25 | Returns an array of pipeline objects: 26 | 27 | ```json 28 | { 29 | "count": 2, 30 | "value": [ 31 | { 32 | "id": 4, 33 | "revision": 2, 34 | "name": "Node.js build pipeline", 35 | "folder": "\\", 36 | "url": "https://dev.azure.com/organization/project/_apis/pipelines/4" 37 | }, 38 | { 39 | "id": 1, 40 | "revision": 1, 41 | "name": "Sample Pipeline", 42 | "folder": "\\", 43 | "url": "https://dev.azure.com/organization/project/_apis/pipelines/1" 44 | } 45 | ] 46 | } 47 | ``` 48 | 49 | ### Error Handling 50 | 51 | - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist 52 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 53 | - Returns generic error messages for other failures 54 | 55 | ### Example Usage 56 | 57 | ```javascript 58 | // Using default project from environment 59 | const result = await callTool('list_pipelines', {}); 60 | 61 | // Specifying project and limiting results 62 | const limitedResult = await callTool('list_pipelines', { 63 | projectId: 'my-project', 64 | top: 10, 65 | orderBy: 'name asc', 66 | }); 67 | ``` 68 | 69 | ## get_pipeline 70 | 71 | Gets details of a specific pipeline. 72 | 73 | ### Parameters 74 | 75 | | Parameter | Type | Required | Description | 76 | | ----------------- | ------ | -------- | ----------------------------------------------------------------- | 77 | | `projectId` | string | No | The ID or name of the project (Default: from environment) | 78 | | `pipelineId` | number | Yes | The numeric ID of the pipeline to retrieve | 79 | | `pipelineVersion` | number | No | The version of the pipeline to retrieve (latest if not specified) | 80 | 81 | ### Response 82 | 83 | Returns a pipeline object with the following structure: 84 | 85 | ```json 86 | { 87 | "id": 4, 88 | "revision": 2, 89 | "name": "Node.js build pipeline", 90 | "folder": "\\", 91 | "url": "https://dev.azure.com/organization/project/_apis/pipelines/4", 92 | "_links": { 93 | "self": { 94 | "href": "https://dev.azure.com/organization/project/_apis/pipelines/4" 95 | }, 96 | "web": { 97 | "href": "https://dev.azure.com/organization/project/_build/definition?definitionId=4" 98 | } 99 | }, 100 | "configuration": { 101 | "path": "azure-pipelines.yml", 102 | "repository": { 103 | "id": "bd0e8130-7fba-4f3b-8559-54760b6e7248", 104 | "type": "azureReposGit" 105 | }, 106 | "type": "yaml" 107 | } 108 | } 109 | ``` 110 | 111 | ### Error Handling 112 | 113 | - Returns `AzureDevOpsResourceNotFoundError` if the pipeline or project does not exist 114 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 115 | - Returns generic error messages for other failures 116 | 117 | ### Example Usage 118 | 119 | ```javascript 120 | // Get latest version of a pipeline 121 | const result = await callTool('get_pipeline', { 122 | pipelineId: 4, 123 | }); 124 | 125 | // Get specific version of a pipeline 126 | const versionResult = await callTool('get_pipeline', { 127 | projectId: 'my-project', 128 | pipelineId: 4, 129 | pipelineVersion: 2, 130 | }); 131 | ``` 132 | 133 | ## trigger_pipeline 134 | 135 | Triggers a run of a specific pipeline. Allows specifying the branch to run on and passing variables to customize the pipeline execution. 136 | 137 | ### Parameters 138 | 139 | | Parameter | Type | Required | Description | 140 | | -------------------- | ------ | -------- | --------------------------------------------------------------------- | 141 | | `projectId` | string | No | The ID or name of the project (Default: from environment) | 142 | | `pipelineId` | number | Yes | The numeric ID of the pipeline to trigger | 143 | | `branch` | string | No | The branch to run the pipeline on (e.g., "main", "feature/my-branch") | 144 | | `variables` | object | No | Variables to pass to the pipeline run | 145 | | `templateParameters` | object | No | Parameters for template-based pipelines | 146 | | `stagesToSkip` | array | No | Stages to skip in the pipeline run | 147 | 148 | #### Variables Format 149 | 150 | ```json 151 | { 152 | "myVariable": { 153 | "value": "my-value", 154 | "isSecret": false 155 | }, 156 | "secretVariable": { 157 | "value": "secret-value", 158 | "isSecret": true 159 | } 160 | } 161 | ``` 162 | 163 | ### Response 164 | 165 | Returns a run object with details about the triggered pipeline run: 166 | 167 | ```json 168 | { 169 | "id": 12345, 170 | "name": "20230215.1", 171 | "createdDate": "2023-02-15T10:30:00Z", 172 | "url": "https://dev.azure.com/organization/project/_apis/pipelines/runs/12345", 173 | "_links": { 174 | "self": { 175 | "href": "https://dev.azure.com/organization/project/_apis/pipelines/runs/12345" 176 | }, 177 | "web": { 178 | "href": "https://dev.azure.com/organization/project/_build/results?buildId=12345" 179 | } 180 | }, 181 | "state": 1, 182 | "result": null, 183 | "variables": { 184 | "myVariable": { 185 | "value": "my-value" 186 | } 187 | } 188 | } 189 | ``` 190 | 191 | ### Error Handling 192 | 193 | - Returns `AzureDevOpsResourceNotFoundError` if the pipeline or project does not exist 194 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 195 | - Returns generic error messages for other failures 196 | 197 | ### Example Usage 198 | 199 | ```javascript 200 | // Trigger a pipeline on the default branch 201 | // In this case, use default project from environment variables 202 | const result = await callTool('trigger_pipeline', { 203 | pipelineId: 4, 204 | }); 205 | 206 | // Trigger a pipeline on a specific branch with variables 207 | const runWithOptions = await callTool('trigger_pipeline', { 208 | projectId: 'my-project', 209 | pipelineId: 4, 210 | branch: 'feature/my-branch', 211 | variables: { 212 | deployEnvironment: { 213 | value: 'staging', 214 | isSecret: false, 215 | }, 216 | }, 217 | }); 218 | ``` 219 | ``` -------------------------------------------------------------------------------- /docs/tools/work-items.md: -------------------------------------------------------------------------------- ```markdown 1 | # Work Item Tools 2 | 3 | This document describes the tools available for working with Azure DevOps work items. 4 | 5 | ## Table of Contents 6 | 7 | - [`get_work_item`](#get_work_item) - Retrieve a specific work item by ID 8 | - [`create_work_item`](#create_work_item) - Create a new work item 9 | - [`list_work_items`](#list_work_items) - List work items in a project 10 | 11 | ## get_work_item 12 | 13 | Retrieves a work item by its ID. 14 | 15 | ### Parameters 16 | 17 | | Parameter | Type | Required | Description | 18 | | ------------ | ------ | -------- | --------------------------------------------------------------------------------- | 19 | | `workItemId` | number | Yes | The ID of the work item to retrieve | 20 | | `expand` | string | No | Controls the level of detail in the response. Defaults to "All" if not specified. Other values: "Relations", "Fields", "None" | 21 | 22 | ### Response 23 | 24 | Returns a work item object with the following structure: 25 | 26 | ```json 27 | { 28 | "id": 123, 29 | "fields": { 30 | "System.Title": "Sample Work Item", 31 | "System.State": "Active", 32 | "System.AssignedTo": "[email protected]", 33 | "System.Description": "Description of the work item" 34 | }, 35 | "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123" 36 | } 37 | ``` 38 | 39 | ### Error Handling 40 | 41 | - Returns `AzureDevOpsResourceNotFoundError` if the work item does not exist 42 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 43 | - Returns generic error messages for other failures 44 | 45 | ### Example Usage 46 | 47 | ```javascript 48 | // Using default expand="All" 49 | const result = await callTool('get_work_item', { 50 | workItemId: 123, 51 | }); 52 | 53 | // Explicitly specifying expand 54 | const minimalResult = await callTool('get_work_item', { 55 | workItemId: 123, 56 | expand: 'None' 57 | }); 58 | ``` 59 | 60 | ## create_work_item 61 | 62 | Creates a new work item in a specified project. 63 | 64 | ### Parameters 65 | 66 | | Parameter | Type | Required | Description | 67 | | ------------------ | ------ | -------- | ------------------------------------------------------------------- | 68 | | `projectId` | string | Yes | The ID or name of the project where the work item will be created | 69 | | `workItemType` | string | Yes | The type of work item to create (e.g., "Task", "Bug", "User Story") | 70 | | `title` | string | Yes | The title of the work item | 71 | | `description` | string | No | The description of the work item | 72 | | `assignedTo` | string | No | The email or name of the user to assign the work item to | 73 | | `areaPath` | string | No | The area path for the work item | 74 | | `iterationPath` | string | No | The iteration path for the work item | 75 | | `priority` | number | No | The priority of the work item | 76 | | `additionalFields` | object | No | Additional fields to set on the work item (key-value pairs) | 77 | 78 | ### Response 79 | 80 | Returns the newly created work item object: 81 | 82 | ```json 83 | { 84 | "id": 124, 85 | "fields": { 86 | "System.Title": "New Work Item", 87 | "System.State": "New", 88 | "System.Description": "Description of the new work item", 89 | "System.AssignedTo": "[email protected]", 90 | "System.AreaPath": "Project\\Team", 91 | "System.IterationPath": "Project\\Sprint 1", 92 | "Microsoft.VSTS.Common.Priority": 2 93 | }, 94 | "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124" 95 | } 96 | ``` 97 | 98 | ### Error Handling 99 | 100 | - Returns validation error if required fields are missing 101 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 102 | - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist 103 | - Returns generic error messages for other failures 104 | 105 | ### Example Usage 106 | 107 | ```javascript 108 | const result = await callTool('create_work_item', { 109 | projectId: 'my-project', 110 | workItemType: 'User Story', 111 | title: 'Implement login functionality', 112 | description: 113 | 'Create a secure login system with email and password authentication', 114 | assignedTo: '[email protected]', 115 | priority: 1, 116 | additionalFields: { 117 | 'Custom.Field': 'Custom Value', 118 | }, 119 | }); 120 | ``` 121 | 122 | ### Implementation Details 123 | 124 | The tool creates a JSON patch document to define the fields of the work item, then calls the Azure DevOps API to create the work item. Each field is added to the document with an 'add' operation, and the document is submitted to the API. 125 | 126 | ## list_work_items 127 | 128 | Lists work items in a specified project. 129 | 130 | ### Parameters 131 | 132 | | Parameter | Type | Required | Description | 133 | | ----------- | ------ | -------- | ----------------------------------------------------- | 134 | | `projectId` | string | Yes | The ID or name of the project to list work items from | 135 | | `teamId` | string | No | The ID of the team to list work items for | 136 | | `queryId` | string | No | ID of a saved work item query | 137 | | `wiql` | string | No | Work Item Query Language (WIQL) query | 138 | | `top` | number | No | Maximum number of work items to return | 139 | | `skip` | number | No | Number of work items to skip | 140 | 141 | ### Response 142 | 143 | Returns an array of work item objects: 144 | 145 | ```json 146 | [ 147 | { 148 | "id": 123, 149 | "fields": { 150 | "System.Title": "Sample Work Item", 151 | "System.State": "Active", 152 | "System.AssignedTo": "[email protected]" 153 | }, 154 | "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123" 155 | }, 156 | { 157 | "id": 124, 158 | "fields": { 159 | "System.Title": "Another Work Item", 160 | "System.State": "New", 161 | "System.AssignedTo": "[email protected]" 162 | }, 163 | "url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124" 164 | } 165 | ] 166 | ``` 167 | 168 | ### Error Handling 169 | 170 | - Returns `AzureDevOpsResourceNotFoundError` if the project does not exist 171 | - Returns `AzureDevOpsAuthenticationError` if authentication fails 172 | - Returns generic error messages for other failures 173 | 174 | ### Example Usage 175 | 176 | ```javascript 177 | const result = await callTool('list_work_items', { 178 | projectId: 'my-project', 179 | wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Task' ORDER BY [System.CreatedDate] DESC", 180 | top: 10, 181 | }); 182 | ``` 183 | ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createWikiPage } from './feature'; 2 | import { handleRequestError } from '../../../shared/errors/handle-request-error'; 3 | 4 | // Mock the AzureDevOpsClient 5 | jest.mock('../../../shared/api/client'); 6 | // Mock the error handler 7 | jest.mock('../../../shared/errors/handle-request-error', () => ({ 8 | handleRequestError: jest.fn(), 9 | })); 10 | 11 | describe('createWikiPage Feature', () => { 12 | let client: any; 13 | const mockPut = jest.fn(); 14 | const mockHandleRequestError = handleRequestError as jest.MockedFunction< 15 | typeof handleRequestError 16 | >; 17 | 18 | const defaultParams = { 19 | wikiId: 'test-wiki', 20 | content: 'Hello world', 21 | pagePath: '/', 22 | }; 23 | 24 | beforeEach(() => { 25 | // Reset mocks for each test 26 | mockPut.mockReset(); 27 | mockHandleRequestError.mockReset(); 28 | 29 | client = { 30 | put: mockPut, 31 | defaults: { 32 | organizationId: 'defaultOrg', 33 | projectId: 'defaultProject', 34 | }, 35 | }; 36 | }); 37 | 38 | it('should call client.put with correct URL and data for default org and project', async () => { 39 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 40 | await createWikiPage(defaultParams, client as any); 41 | 42 | expect(mockPut).toHaveBeenCalledTimes(1); 43 | expect(mockPut).toHaveBeenCalledWith( 44 | 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 45 | { content: 'Hello world' }, 46 | ); 47 | }); 48 | 49 | it('should call client.put with correct URL when projectId is explicitly provided', async () => { 50 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 51 | const paramsWithProject = { 52 | ...defaultParams, 53 | projectId: 'customProject', 54 | }; 55 | await createWikiPage(paramsWithProject, client as any); 56 | 57 | expect(mockPut).toHaveBeenCalledWith( 58 | 'defaultOrg/customProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 59 | { content: 'Hello world' }, 60 | ); 61 | }); 62 | 63 | it('should call client.put with correct URL when organizationId is explicitly provided', async () => { 64 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 65 | const paramsWithOrg = { 66 | ...defaultParams, 67 | organizationId: 'customOrg', 68 | }; 69 | await createWikiPage(paramsWithOrg, client as any); 70 | 71 | expect(mockPut).toHaveBeenCalledWith( 72 | 'customOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 73 | { content: 'Hello world' }, 74 | ); 75 | }); 76 | 77 | it('should call client.put with correct URL when projectId is null (project-level wiki)', async () => { 78 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 79 | const paramsWithNullProject = { 80 | ...defaultParams, 81 | projectId: null, // Explicitly null for project-level resources that don't need a project 82 | }; 83 | 84 | // Client default for projectId should also be null or undefined in this scenario 85 | const clientWithoutProject = { 86 | put: mockPut, 87 | defaults: { 88 | organizationId: 'defaultOrg', 89 | projectId: undefined, 90 | }, 91 | }; 92 | 93 | await createWikiPage(paramsWithNullProject, clientWithoutProject as any); 94 | 95 | expect(mockPut).toHaveBeenCalledWith( 96 | 'defaultOrg/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 97 | { content: 'Hello world' }, 98 | ); 99 | }); 100 | 101 | it('should correctly encode pagePath in the URL', async () => { 102 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 103 | const paramsWithPath = { 104 | ...defaultParams, 105 | pagePath: '/My Test Page/Sub Page', 106 | }; 107 | await createWikiPage(paramsWithPath, client as any); 108 | 109 | expect(mockPut).toHaveBeenCalledWith( 110 | 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2FMy%20Test%20Page%2FSub%20Page&api-version=7.1-preview.1', 111 | { content: 'Hello world' }, 112 | ); 113 | }); 114 | 115 | it('should use default pagePath "/" if pagePath is null', async () => { 116 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 117 | const paramsWithPath = { 118 | ...defaultParams, 119 | pagePath: null, // Explicitly null 120 | }; 121 | await createWikiPage(paramsWithPath, client as any); 122 | 123 | expect(mockPut).toHaveBeenCalledWith( 124 | 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 125 | { content: 'Hello world' }, 126 | ); 127 | }); 128 | 129 | it('should include comment in request body when provided', async () => { 130 | mockPut.mockResolvedValue({ data: { some: 'response' } }); 131 | const paramsWithComment = { 132 | ...defaultParams, 133 | comment: 'Initial page creation', 134 | }; 135 | await createWikiPage(paramsWithComment, client as any); 136 | 137 | expect(mockPut).toHaveBeenCalledWith( 138 | 'defaultOrg/defaultProject/_apis/wiki/wikis/test-wiki/pages?path=%2F&api-version=7.1-preview.1', 139 | { content: 'Hello world', comment: 'Initial page creation' }, 140 | ); 141 | }); 142 | 143 | it('should return the data from the response on success', async () => { 144 | const expectedResponse = { id: '123', path: '/', content: 'Hello world' }; 145 | mockPut.mockResolvedValue({ data: expectedResponse }); 146 | const result = await createWikiPage(defaultParams, client as any); 147 | 148 | expect(result).toEqual(expectedResponse); 149 | }); 150 | 151 | // Skip this test for now as it requires complex mocking of environment variables 152 | it.skip('should throw if organizationId is not provided and not set in defaults', async () => { 153 | const clientWithoutOrg = { 154 | put: mockPut, 155 | defaults: { 156 | projectId: 'defaultProject', 157 | organizationId: undefined, 158 | }, 159 | }; 160 | 161 | const paramsNoOrg = { 162 | ...defaultParams, 163 | organizationId: null, // Explicitly null and no default 164 | }; 165 | 166 | // This test is skipped because it requires complex mocking of environment variables 167 | // which is difficult to do in the current test setup 168 | await expect( 169 | createWikiPage(paramsNoOrg, clientWithoutOrg as any), 170 | ).rejects.toThrow( 171 | 'Organization ID is not defined. Please provide it or set a default.', 172 | ); 173 | expect(mockPut).not.toHaveBeenCalled(); 174 | }); 175 | 176 | it('should call handleRequestError if client.put throws an error', async () => { 177 | const error = new Error('API Error'); 178 | mockPut.mockRejectedValue(error); 179 | mockHandleRequestError.mockImplementation(() => { 180 | throw new Error('Handled Error'); 181 | }); 182 | 183 | await expect(createWikiPage(defaultParams, client as any)).rejects.toThrow( 184 | 'Handled Error', 185 | ); 186 | expect(mockHandleRequestError).toHaveBeenCalledTimes(1); 187 | expect(mockHandleRequestError).toHaveBeenCalledWith( 188 | error, 189 | 'Failed to create or update wiki page', 190 | ); 191 | }); 192 | }); 193 | ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { getProjectDetails } from './feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '@/shared/test/test-helpers'; 7 | 8 | describe('getProjectDetails integration', () => { 9 | let connection: WebApi | null = null; 10 | let projectName: string; 11 | 12 | beforeAll(async () => { 13 | // Get a real connection using environment variables 14 | connection = await getTestConnection(); 15 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 16 | }); 17 | 18 | test('should retrieve basic project details from Azure DevOps', async () => { 19 | // Skip if no connection is available 20 | if (shouldSkipIntegrationTest()) { 21 | return; 22 | } 23 | 24 | // This connection must be available if we didn't skip 25 | if (!connection) { 26 | throw new Error( 27 | 'Connection should be available when test is not skipped', 28 | ); 29 | } 30 | 31 | // Act - make an actual API call to Azure DevOps 32 | const result = await getProjectDetails(connection, { 33 | projectId: projectName, 34 | }); 35 | 36 | // Assert on the actual response 37 | expect(result).toBeDefined(); 38 | expect(result.name).toBe(projectName); 39 | expect(result.id).toBeDefined(); 40 | expect(result.url).toBeDefined(); 41 | expect(result.state).toBeDefined(); 42 | 43 | // Verify basic project structure 44 | expect(result.visibility).toBeDefined(); 45 | expect(result.lastUpdateTime).toBeDefined(); 46 | expect(result.capabilities).toBeDefined(); 47 | }); 48 | 49 | test('should retrieve project details with teams from Azure DevOps', async () => { 50 | // Skip if no connection is available 51 | if (shouldSkipIntegrationTest()) { 52 | return; 53 | } 54 | 55 | // This connection must be available if we didn't skip 56 | if (!connection) { 57 | throw new Error( 58 | 'Connection should be available when test is not skipped', 59 | ); 60 | } 61 | 62 | // Act - make an actual API call to Azure DevOps 63 | const result = await getProjectDetails(connection, { 64 | projectId: projectName, 65 | includeTeams: true, 66 | }); 67 | 68 | // Assert on the actual response 69 | expect(result).toBeDefined(); 70 | expect(result.teams).toBeDefined(); 71 | expect(Array.isArray(result.teams)).toBe(true); 72 | 73 | // There should be at least one team (the default team) 74 | if (result.teams && result.teams.length > 0) { 75 | const team = result.teams[0]; 76 | expect(team.id).toBeDefined(); 77 | expect(team.name).toBeDefined(); 78 | expect(team.url).toBeDefined(); 79 | } 80 | }); 81 | 82 | test('should retrieve project details with process information from Azure DevOps', async () => { 83 | // Skip if no connection is available 84 | if (shouldSkipIntegrationTest()) { 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 | // Act - make an actual API call to Azure DevOps 96 | const result = await getProjectDetails(connection, { 97 | projectId: projectName, 98 | includeProcess: true, 99 | }); 100 | 101 | // Assert on the actual response 102 | expect(result).toBeDefined(); 103 | expect(result.process).toBeDefined(); 104 | expect(result.process?.name).toBeDefined(); 105 | }); 106 | 107 | test('should retrieve project details with work item types from Azure DevOps', async () => { 108 | // Skip if no connection is available 109 | if (shouldSkipIntegrationTest()) { 110 | return; 111 | } 112 | 113 | // This connection must be available if we didn't skip 114 | if (!connection) { 115 | throw new Error( 116 | 'Connection should be available when test is not skipped', 117 | ); 118 | } 119 | 120 | // Act - make an actual API call to Azure DevOps 121 | const result = await getProjectDetails(connection, { 122 | projectId: projectName, 123 | includeProcess: true, 124 | includeWorkItemTypes: true, 125 | }); 126 | 127 | // Assert on the actual response 128 | expect(result).toBeDefined(); 129 | expect(result.process).toBeDefined(); 130 | expect(result.process?.workItemTypes).toBeDefined(); 131 | expect(Array.isArray(result.process?.workItemTypes)).toBe(true); 132 | 133 | // There should be at least one work item type 134 | if ( 135 | result.process?.workItemTypes && 136 | result.process.workItemTypes.length > 0 137 | ) { 138 | const workItemType = result.process.workItemTypes[0]; 139 | expect(workItemType.name).toBeDefined(); 140 | expect(workItemType.description).toBeDefined(); 141 | expect(workItemType.states).toBeDefined(); 142 | } 143 | }); 144 | 145 | test('should retrieve project details with fields from Azure DevOps', async () => { 146 | // Skip if no connection is available 147 | if (shouldSkipIntegrationTest()) { 148 | return; 149 | } 150 | 151 | // This connection must be available if we didn't skip 152 | if (!connection) { 153 | throw new Error( 154 | 'Connection should be available when test is not skipped', 155 | ); 156 | } 157 | 158 | // Act - make an actual API call to Azure DevOps 159 | const result = await getProjectDetails(connection, { 160 | projectId: projectName, 161 | includeProcess: true, 162 | includeWorkItemTypes: true, 163 | includeFields: true, 164 | }); 165 | 166 | // Assert on the actual response 167 | expect(result).toBeDefined(); 168 | expect(result.process).toBeDefined(); 169 | expect(result.process?.workItemTypes).toBeDefined(); 170 | 171 | // There should be at least one work item type with fields 172 | if ( 173 | result.process?.workItemTypes && 174 | result.process.workItemTypes.length > 0 175 | ) { 176 | const workItemType = result.process.workItemTypes[0]; 177 | expect(workItemType.fields).toBeDefined(); 178 | expect(Array.isArray(workItemType.fields)).toBe(true); 179 | 180 | // There should be at least one field (like Title) 181 | if (workItemType.fields && workItemType.fields.length > 0) { 182 | const field = workItemType.fields[0]; 183 | expect(field.name).toBeDefined(); 184 | expect(field.referenceName).toBeDefined(); 185 | } 186 | } 187 | }); 188 | 189 | test('should throw error when project is not found', async () => { 190 | // Skip if no connection is available 191 | if (shouldSkipIntegrationTest()) { 192 | return; 193 | } 194 | 195 | // This connection must be available if we didn't skip 196 | if (!connection) { 197 | throw new Error( 198 | 'Connection should be available when test is not skipped', 199 | ); 200 | } 201 | 202 | // Use a non-existent project name 203 | const nonExistentProjectName = 'non-existent-project-' + Date.now(); 204 | 205 | // Act & Assert - should throw an error for non-existent project 206 | await expect( 207 | getProjectDetails(connection, { 208 | projectId: nonExistentProjectName, 209 | }), 210 | ).rejects.toThrow(/not found|Failed to get project/); 211 | }); 212 | }); 213 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/schemas.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | import { defaultProject, defaultOrg } from '../../utils/environment'; 3 | 4 | /** 5 | * Schema for creating a pull request 6 | */ 7 | export const CreatePullRequestSchema = z.object({ 8 | projectId: z 9 | .string() 10 | .optional() 11 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 12 | organizationId: z 13 | .string() 14 | .optional() 15 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 16 | repositoryId: z.string().describe('The ID or name of the repository'), 17 | title: z.string().describe('The title of the pull request'), 18 | description: z 19 | .string() 20 | .optional() 21 | .describe('The description of the pull request (markdown is supported)'), 22 | sourceRefName: z 23 | .string() 24 | .describe('The source branch name (e.g., refs/heads/feature-branch)'), 25 | targetRefName: z 26 | .string() 27 | .describe('The target branch name (e.g., refs/heads/main)'), 28 | reviewers: z 29 | .array(z.string()) 30 | .optional() 31 | .describe('List of reviewer email addresses or IDs'), 32 | isDraft: z 33 | .boolean() 34 | .optional() 35 | .describe('Whether the pull request should be created as a draft'), 36 | workItemRefs: z 37 | .array(z.number()) 38 | .optional() 39 | .describe('List of work item IDs to link to the pull request'), 40 | additionalProperties: z 41 | .record(z.string(), z.any()) 42 | .optional() 43 | .describe('Additional properties to set on the pull request'), 44 | }); 45 | 46 | /** 47 | * Schema for listing pull requests 48 | */ 49 | export const ListPullRequestsSchema = z.object({ 50 | projectId: z 51 | .string() 52 | .optional() 53 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 54 | organizationId: z 55 | .string() 56 | .optional() 57 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 58 | repositoryId: z.string().describe('The ID or name of the repository'), 59 | status: z 60 | .enum(['all', 'active', 'completed', 'abandoned']) 61 | .optional() 62 | .describe('Filter by pull request status'), 63 | creatorId: z 64 | .string() 65 | .optional() 66 | .describe('Filter by creator ID (must be a UUID string)'), 67 | reviewerId: z 68 | .string() 69 | .optional() 70 | .describe('Filter by reviewer ID (must be a UUID string)'), 71 | sourceRefName: z.string().optional().describe('Filter by source branch name'), 72 | targetRefName: z.string().optional().describe('Filter by target branch name'), 73 | top: z 74 | .number() 75 | .default(10) 76 | .describe('Maximum number of pull requests to return (default: 10)'), 77 | skip: z 78 | .number() 79 | .optional() 80 | .describe('Number of pull requests to skip for pagination'), 81 | }); 82 | 83 | /** 84 | * Schema for getting pull request comments 85 | */ 86 | export const GetPullRequestCommentsSchema = z.object({ 87 | projectId: z 88 | .string() 89 | .optional() 90 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 91 | organizationId: z 92 | .string() 93 | .optional() 94 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 95 | repositoryId: z.string().describe('The ID or name of the repository'), 96 | pullRequestId: z.number().describe('The ID of the pull request'), 97 | threadId: z 98 | .number() 99 | .optional() 100 | .describe('The ID of the specific thread to get comments from'), 101 | includeDeleted: z 102 | .boolean() 103 | .optional() 104 | .describe('Whether to include deleted comments'), 105 | top: z 106 | .number() 107 | .optional() 108 | .describe('Maximum number of threads/comments to return'), 109 | }); 110 | 111 | /** 112 | * Schema for adding a comment to a pull request 113 | */ 114 | export const AddPullRequestCommentSchema = z 115 | .object({ 116 | projectId: z 117 | .string() 118 | .optional() 119 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 120 | organizationId: z 121 | .string() 122 | .optional() 123 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 124 | repositoryId: z.string().describe('The ID or name of the repository'), 125 | pullRequestId: z.number().describe('The ID of the pull request'), 126 | content: z.string().describe('The content of the comment in markdown'), 127 | threadId: z 128 | .number() 129 | .optional() 130 | .describe('The ID of the thread to add the comment to'), 131 | parentCommentId: z 132 | .number() 133 | .optional() 134 | .describe( 135 | 'ID of the parent comment when replying to an existing comment', 136 | ), 137 | filePath: z 138 | .string() 139 | .optional() 140 | .describe('The path of the file to comment on (for new thread on file)'), 141 | lineNumber: z 142 | .number() 143 | .optional() 144 | .describe('The line number to comment on (for new thread on file)'), 145 | status: z 146 | .enum([ 147 | 'active', 148 | 'fixed', 149 | 'wontFix', 150 | 'closed', 151 | 'pending', 152 | 'byDesign', 153 | 'unknown', 154 | ]) 155 | .optional() 156 | .describe('The status to set for a new thread'), 157 | }) 158 | .superRefine((data, ctx) => { 159 | // If we're creating a new thread (no threadId), status is required 160 | if (!data.threadId && !data.status) { 161 | ctx.addIssue({ 162 | code: z.ZodIssueCode.custom, 163 | message: 'Status is required when creating a new thread', 164 | path: ['status'], 165 | }); 166 | } 167 | }); 168 | 169 | /** 170 | * Schema for updating a pull request 171 | */ 172 | export const UpdatePullRequestSchema = z.object({ 173 | projectId: z 174 | .string() 175 | .optional() 176 | .describe(`The ID or name of the project (Default: ${defaultProject})`), 177 | organizationId: z 178 | .string() 179 | .optional() 180 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`), 181 | repositoryId: z.string().describe('The ID or name of the repository'), 182 | pullRequestId: z.number().describe('The ID of the pull request to update'), 183 | title: z 184 | .string() 185 | .optional() 186 | .describe('The updated title of the pull request'), 187 | description: z 188 | .string() 189 | .optional() 190 | .describe('The updated description of the pull request'), 191 | status: z 192 | .enum(['active', 'abandoned', 'completed']) 193 | .optional() 194 | .describe('The updated status of the pull request'), 195 | isDraft: z 196 | .boolean() 197 | .optional() 198 | .describe( 199 | 'Whether the pull request should be marked as a draft (true) or unmarked (false)', 200 | ), 201 | addWorkItemIds: z 202 | .array(z.number()) 203 | .optional() 204 | .describe('List of work item IDs to link to the pull request'), 205 | removeWorkItemIds: z 206 | .array(z.number()) 207 | .optional() 208 | .describe('List of work item IDs to unlink from the pull request'), 209 | addReviewers: z 210 | .array(z.string()) 211 | .optional() 212 | .describe('List of reviewer email addresses or IDs to add'), 213 | removeReviewers: z 214 | .array(z.string()) 215 | .optional() 216 | .describe('List of reviewer email addresses or IDs to remove'), 217 | additionalProperties: z 218 | .record(z.string(), z.any()) 219 | .optional() 220 | .describe('Additional properties to update on the pull request'), 221 | }); 222 | ``` -------------------------------------------------------------------------------- /project-management/planning/the-dream-team.md: -------------------------------------------------------------------------------- ```markdown 1 | Below is the **Dream Team Documentation** for building the Azure DevOps MCP server. This document outlines the ideal roles and skill sets required to ensure the project's success, from development to deployment. Each role is carefully selected to address the technical, security, and operational challenges of building a robust, AI-integrated server. 2 | 3 | --- 4 | 5 | ## Dream Team Documentation: Building the Azure DevOps MCP Server 6 | 7 | ### Overview 8 | 9 | The Azure DevOps MCP server is a complex tool that requires a multidisciplinary team with expertise in software development, Azure DevOps, security, testing, documentation, project management, and AI integration. The following roles are essential to ensure the server is built efficiently, securely, and in alignment with the Model Context Protocol (MCP) standards. 10 | 11 | ### Key Roles and Responsibilities 12 | 13 | #### 1. **Full-Stack Developer (Typescript/Node.js)** 14 | 15 | - **Responsibilities**: 16 | - Implement the server's core functionality using Typescript and Node.js. 17 | - Develop and maintain MCP tools (e.g., `list_projects`, `create_work_item`). 18 | - Write tests as part of the implementation process (TDD). 19 | - Integrate with the MCP Typescript SDK and Azure DevOps APIs. 20 | - Write clean, modular, and efficient code following best practices. 21 | - Ensure code quality through comprehensive unit and integration tests. 22 | - Build automated testing pipelines for continuous integration. 23 | - Perform integration testing across components. 24 | - **Required Skills**: 25 | - Proficiency in Typescript and Node.js. 26 | - Strong testing skills and experience with test frameworks (e.g., Jest). 27 | - Experience writing testable code and following TDD practices. 28 | - Experience with REST APIs and asynchronous programming. 29 | - Familiarity with Git and version control systems. 30 | - Understanding of modular software design. 31 | - Experience with API testing and mocking tools. 32 | 33 | #### 2. **Azure DevOps API Expert** 34 | 35 | - **Responsibilities**: 36 | - Guide the team on effectively using Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). 37 | - Ensure the server leverages Azure DevOps features optimally (e.g., repository operations, pipelines). 38 | - Assist in mapping MCP tools to the correct API endpoints. 39 | - Troubleshoot API-related issues and optimize API usage. 40 | - Help develop tests for Azure DevOps API integrations. 41 | - **Required Skills**: 42 | - Deep understanding of Azure DevOps services and their REST APIs. 43 | - Experience with Azure DevOps workflows (e.g., repositories, work items, pipelines). 44 | - Knowledge of Azure DevOps authentication mechanisms (PAT, AAD). 45 | - Ability to interpret API documentation and handle rate limits. 46 | - Experience testing API integrations. 47 | 48 | #### 3. **Security Specialist** 49 | 50 | - **Responsibilities**: 51 | - Design and implement secure authentication methods (PAT and AAD). 52 | - Ensure credentials are stored and managed securely (e.g., environment variables). 53 | - Scope permissions to the minimum required for each tool. 54 | - Implement error handling and logging without exposing sensitive data. 55 | - Conduct security reviews and recommend improvements. 56 | - Develop security tests and validation procedures. 57 | - **Required Skills**: 58 | - Expertise in API security, authentication, and authorization. 59 | - Familiarity with Azure Active Directory and PAT management. 60 | - Knowledge of secure coding practices and vulnerability prevention. 61 | - Experience with logging, auditing, and compliance. 62 | - Experience with security testing tools and methodologies. 63 | 64 | #### 4. **Technical Writer** 65 | 66 | - **Responsibilities**: 67 | - Create comprehensive documentation, including setup guides, tool descriptions, and usage examples. 68 | - Write clear API references and troubleshooting tips. 69 | - Ensure documentation is accessible to both technical and non-technical users. 70 | - Maintain up-to-date documentation as the server evolves. 71 | - **Required Skills**: 72 | - Strong technical writing and communication skills. 73 | - Ability to explain complex concepts simply. 74 | - Experience documenting APIs and developer tools. 75 | - Familiarity with Markdown and documentation platforms (e.g., GitHub README). 76 | 77 | #### 5. **Project Manager** 78 | 79 | - **Responsibilities**: 80 | - Coordinate the team's efforts and manage the project timeline. 81 | - Track progress using Azure Boards or similar tools. 82 | - Facilitate communication and resolve blockers. 83 | - Ensure the project stays on scope and meets deadlines. 84 | - Manage stakeholder expectations and provide status updates. 85 | - **Required Skills**: 86 | - Experience in agile project management. 87 | - Proficiency with project tracking tools (e.g., Azure Boards, Jira). 88 | - Strong organizational and leadership skills. 89 | - Ability to manage remote or distributed teams. 90 | 91 | #### 6. **AI Integration Consultant** 92 | 93 | - **Responsibilities**: 94 | - Advise on how the server can best integrate with AI models (e.g., Claude Desktop). 95 | - Ensure tools are designed to support AI-driven workflows (e.g., user story to pull request). 96 | - Provide insights into MCP's AI integration capabilities. 97 | - Assist in testing AI interactions with the server. 98 | - **Required Skills**: 99 | - Experience with AI model integration and workflows. 100 | - Understanding of the Model Context Protocol (MCP). 101 | - Familiarity with AI tools like Claude Desktop. 102 | - Ability to bridge AI and software development domains. 103 | 104 | --- 105 | 106 | ### Team Structure and Collaboration 107 | 108 | - **Core Team**: Full-Stack Developer, Azure DevOps API Expert, Security Specialist. 109 | - **Support Roles**: Technical Writer, Project Manager, AI Integration Consultant. 110 | - **Collaboration**: Use Agile methodologies with bi-weekly sprints, daily stand-ups, and regular retrospectives to iterate efficiently. 111 | - **Communication Tools**: Slack or Microsoft Teams for real-time communication, Azure Boards for task tracking, and GitHub/Azure DevOps for version control and code reviews. 112 | 113 | --- 114 | 115 | ### Why This Team? 116 | 117 | Each role addresses a critical aspect of the project: 118 | 119 | - The **Full-Stack Developer** builds the server using modern technologies like Typescript and Node.js, integrating testing throughout the development process. 120 | - The **Azure DevOps API Expert** ensures seamless integration with Azure DevOps services. 121 | - The **Security Specialist** safeguards the server against vulnerabilities. 122 | - The **Technical Writer** makes the server user-friendly with clear documentation. 123 | - The **Project Manager** keeps the team aligned and on schedule. 124 | - The **AI Integration Consultant** ensures the server meets AI-driven workflow requirements. 125 | 126 | This dream team combines technical expertise, security, integrated quality assurance, and project management to deliver a high-quality, secure, and user-friendly Azure DevOps MCP server. Testing is built into our development process, not treated as a separate concern. 127 | ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { getRepositoryDetails } from './feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '@/shared/test/test-helpers'; 7 | 8 | describe('getRepositoryDetails integration', () => { 9 | let connection: WebApi | null = null; 10 | let projectName: string; 11 | 12 | beforeAll(async () => { 13 | // Get a real connection using environment variables 14 | connection = await getTestConnection(); 15 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 16 | }); 17 | 18 | test('should retrieve repository details from Azure DevOps', async () => { 19 | // Skip if no connection is available 20 | if (shouldSkipIntegrationTest()) { 21 | return; 22 | } 23 | 24 | // This connection must be available if we didn't skip 25 | if (!connection) { 26 | throw new Error( 27 | 'Connection should be available when test is not skipped', 28 | ); 29 | } 30 | 31 | // First, get a list of repos to find one to test with 32 | const gitApi = await connection.getGitApi(); 33 | const repos = await gitApi.getRepositories(projectName); 34 | 35 | // Skip if no repos are available 36 | if (!repos || repos.length === 0) { 37 | console.log('Skipping test: No repositories available in the project'); 38 | return; 39 | } 40 | 41 | // Use the first repo as a test subject 42 | const testRepo = repos[0]; 43 | 44 | // Act - make an actual API call to Azure DevOps 45 | const result = await getRepositoryDetails(connection, { 46 | projectId: projectName, 47 | repositoryId: testRepo.name || testRepo.id || '', 48 | }); 49 | 50 | // Assert on the actual response 51 | expect(result).toBeDefined(); 52 | expect(result.repository).toBeDefined(); 53 | expect(result.repository.id).toBe(testRepo.id); 54 | expect(result.repository.name).toBe(testRepo.name); 55 | expect(result.repository.project).toBeDefined(); 56 | if (result.repository.project) { 57 | expect(result.repository.project.name).toBe(projectName); 58 | } 59 | }); 60 | 61 | test('should retrieve repository details with statistics', async () => { 62 | // Skip if no connection is available 63 | if (shouldSkipIntegrationTest()) { 64 | return; 65 | } 66 | 67 | // This connection must be available if we didn't skip 68 | if (!connection) { 69 | throw new Error( 70 | 'Connection should be available when test is not skipped', 71 | ); 72 | } 73 | 74 | // First, get a list of repos to find one to test with 75 | const gitApi = await connection.getGitApi(); 76 | const repos = await gitApi.getRepositories(projectName); 77 | 78 | // Skip if no repos are available 79 | if (!repos || repos.length === 0) { 80 | console.log('Skipping test: No repositories available in the project'); 81 | return; 82 | } 83 | 84 | // Use the first repo as a test subject 85 | const testRepo = repos[0]; 86 | 87 | // Act - make an actual API call to Azure DevOps 88 | const result = await getRepositoryDetails(connection, { 89 | projectId: projectName, 90 | repositoryId: testRepo.name || testRepo.id || '', 91 | includeStatistics: true, 92 | }); 93 | 94 | // Assert on the actual response 95 | expect(result).toBeDefined(); 96 | expect(result.repository).toBeDefined(); 97 | expect(result.repository.id).toBe(testRepo.id); 98 | expect(result.statistics).toBeDefined(); 99 | expect(Array.isArray(result.statistics?.branches)).toBe(true); 100 | }); 101 | 102 | test('should retrieve repository details with refs', async () => { 103 | // Skip if no connection is available 104 | if (shouldSkipIntegrationTest()) { 105 | return; 106 | } 107 | 108 | // This connection must be available if we didn't skip 109 | if (!connection) { 110 | throw new Error( 111 | 'Connection should be available when test is not skipped', 112 | ); 113 | } 114 | 115 | // First, get a list of repos to find one to test with 116 | const gitApi = await connection.getGitApi(); 117 | const repos = await gitApi.getRepositories(projectName); 118 | 119 | // Skip if no repos are available 120 | if (!repos || repos.length === 0) { 121 | console.log('Skipping test: No repositories available in the project'); 122 | return; 123 | } 124 | 125 | // Use the first repo as a test subject 126 | const testRepo = repos[0]; 127 | 128 | // Act - make an actual API call to Azure DevOps 129 | const result = await getRepositoryDetails(connection, { 130 | projectId: projectName, 131 | repositoryId: testRepo.name || testRepo.id || '', 132 | includeRefs: true, 133 | }); 134 | 135 | // Assert on the actual response 136 | expect(result).toBeDefined(); 137 | expect(result.repository).toBeDefined(); 138 | expect(result.repository.id).toBe(testRepo.id); 139 | expect(result.refs).toBeDefined(); 140 | expect(result.refs?.value).toBeDefined(); 141 | expect(Array.isArray(result.refs?.value)).toBe(true); 142 | expect(typeof result.refs?.count).toBe('number'); 143 | }); 144 | 145 | test('should retrieve repository details with refs filtered by heads/', async () => { 146 | // Skip if no connection is available 147 | if (shouldSkipIntegrationTest()) { 148 | return; 149 | } 150 | 151 | // This connection must be available if we didn't skip 152 | if (!connection) { 153 | throw new Error( 154 | 'Connection should be available when test is not skipped', 155 | ); 156 | } 157 | 158 | // First, get a list of repos to find one to test with 159 | const gitApi = await connection.getGitApi(); 160 | const repos = await gitApi.getRepositories(projectName); 161 | 162 | // Skip if no repos are available 163 | if (!repos || repos.length === 0) { 164 | console.log('Skipping test: No repositories available in the project'); 165 | return; 166 | } 167 | 168 | // Use the first repo as a test subject 169 | const testRepo = repos[0]; 170 | 171 | // Act - make an actual API call to Azure DevOps 172 | const result = await getRepositoryDetails(connection, { 173 | projectId: projectName, 174 | repositoryId: testRepo.name || testRepo.id || '', 175 | includeRefs: true, 176 | refFilter: 'heads/', 177 | }); 178 | 179 | // Assert on the actual response 180 | expect(result).toBeDefined(); 181 | expect(result.repository).toBeDefined(); 182 | expect(result.refs).toBeDefined(); 183 | expect(result.refs?.value).toBeDefined(); 184 | 185 | // All refs should start with refs/heads/ 186 | if (result.refs && result.refs.value.length > 0) { 187 | result.refs.value.forEach((ref) => { 188 | expect(ref.name).toMatch(/^refs\/heads\//); 189 | }); 190 | } 191 | }); 192 | 193 | test('should throw error when repository is not found', async () => { 194 | // Skip if no connection is available 195 | if (shouldSkipIntegrationTest()) { 196 | return; 197 | } 198 | 199 | // This connection must be available if we didn't skip 200 | if (!connection) { 201 | throw new Error( 202 | 'Connection should be available when test is not skipped', 203 | ); 204 | } 205 | 206 | // Use a non-existent repository name 207 | const nonExistentRepoName = 'non-existent-repo-' + Date.now(); 208 | 209 | // Act & Assert - should throw an error for non-existent repo 210 | await expect( 211 | getRepositoryDetails(connection, { 212 | projectId: projectName, 213 | repositoryId: nonExistentRepoName, 214 | }), 215 | ).rejects.toThrow(/not found|Failed to get repository/); 216 | }); 217 | }); 218 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/list-pull-requests/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { listPullRequests } from './feature'; 3 | import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces'; 4 | 5 | describe('listPullRequests', () => { 6 | afterEach(() => { 7 | jest.resetAllMocks(); 8 | }); 9 | 10 | test('should return pull requests successfully with pagination metadata', async () => { 11 | // Mock data 12 | const mockPullRequests = [ 13 | { 14 | pullRequestId: 1, 15 | title: 'Test PR 1', 16 | description: 'Test PR description 1', 17 | }, 18 | { 19 | pullRequestId: 2, 20 | title: 'Test PR 2', 21 | description: 'Test PR description 2', 22 | }, 23 | ]; 24 | 25 | // Setup mock connection 26 | const mockGitApi = { 27 | getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), 28 | }; 29 | 30 | const mockConnection: any = { 31 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 32 | }; 33 | 34 | // Call the function with test parameters 35 | const projectId = 'test-project'; 36 | const repositoryId = 'test-repo'; 37 | const options = { 38 | projectId, 39 | repositoryId, 40 | status: 'active' as const, 41 | top: 10, 42 | }; 43 | 44 | const result = await listPullRequests( 45 | mockConnection as WebApi, 46 | projectId, 47 | repositoryId, 48 | options, 49 | ); 50 | 51 | // Verify results 52 | expect(result).toEqual({ 53 | count: 2, 54 | value: mockPullRequests, 55 | hasMoreResults: false, 56 | warning: undefined, 57 | }); 58 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 59 | expect(mockGitApi.getPullRequests).toHaveBeenCalledTimes(1); 60 | expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( 61 | repositoryId, 62 | { status: PullRequestStatus.Active }, 63 | projectId, 64 | undefined, // maxCommentLength 65 | 0, // skip 66 | 10, // top 67 | ); 68 | }); 69 | 70 | test('should return empty array when no pull requests exist', async () => { 71 | // Setup mock connection 72 | const mockGitApi = { 73 | getPullRequests: jest.fn().mockResolvedValue(null), 74 | }; 75 | 76 | const mockConnection: any = { 77 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 78 | }; 79 | 80 | // Call the function with test parameters 81 | const projectId = 'test-project'; 82 | const repositoryId = 'test-repo'; 83 | const options = { projectId, repositoryId }; 84 | 85 | const result = await listPullRequests( 86 | mockConnection as WebApi, 87 | projectId, 88 | repositoryId, 89 | options, 90 | ); 91 | 92 | // Verify results 93 | expect(result).toEqual({ 94 | count: 0, 95 | value: [], 96 | hasMoreResults: false, 97 | warning: undefined, 98 | }); 99 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 100 | expect(mockGitApi.getPullRequests).toHaveBeenCalledTimes(1); 101 | }); 102 | 103 | test('should handle all filter options correctly', async () => { 104 | // Setup mock connection 105 | const mockGitApi = { 106 | getPullRequests: jest.fn().mockResolvedValue([]), 107 | }; 108 | 109 | const mockConnection: any = { 110 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 111 | }; 112 | 113 | // Call with all options 114 | const projectId = 'test-project'; 115 | const repositoryId = 'test-repo'; 116 | const options = { 117 | projectId, 118 | repositoryId, 119 | status: 'completed' as const, 120 | creatorId: 'a8a8a8a8-a8a8-a8a8-a8a8-a8a8a8a8a8a8', 121 | reviewerId: 'b9b9b9b9-b9b9-b9b9-b9b9-b9b9b9b9b9b9', 122 | sourceRefName: 'refs/heads/source-branch', 123 | targetRefName: 'refs/heads/target-branch', 124 | top: 5, 125 | skip: 10, 126 | }; 127 | 128 | await listPullRequests( 129 | mockConnection as WebApi, 130 | projectId, 131 | repositoryId, 132 | options, 133 | ); 134 | 135 | // Verify the search criteria was constructed correctly 136 | expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( 137 | repositoryId, 138 | { 139 | status: PullRequestStatus.Completed, 140 | creatorId: 'a8a8a8a8-a8a8-a8a8-a8a8-a8a8a8a8a8a8', 141 | reviewerId: 'b9b9b9b9-b9b9-b9b9-b9b9-b9b9b9b9b9b9', 142 | sourceRefName: 'refs/heads/source-branch', 143 | targetRefName: 'refs/heads/target-branch', 144 | }, 145 | projectId, 146 | undefined, // maxCommentLength 147 | 10, // skip 148 | 5, // top 149 | ); 150 | }); 151 | 152 | test('should throw error when API call fails', async () => { 153 | // Setup mock connection 154 | const errorMessage = 'API error'; 155 | const mockConnection: any = { 156 | getGitApi: jest.fn().mockImplementation(() => ({ 157 | getPullRequests: jest.fn().mockRejectedValue(new Error(errorMessage)), 158 | })), 159 | }; 160 | 161 | // Call the function with test parameters 162 | const projectId = 'test-project'; 163 | const repositoryId = 'test-repo'; 164 | const options = { projectId, repositoryId }; 165 | 166 | // Verify error handling 167 | await expect( 168 | listPullRequests( 169 | mockConnection as WebApi, 170 | projectId, 171 | repositoryId, 172 | options, 173 | ), 174 | ).rejects.toThrow(`Failed to list pull requests: ${errorMessage}`); 175 | }); 176 | 177 | test('should use default pagination values when not provided', async () => { 178 | // Mock data 179 | const mockPullRequests = [ 180 | { pullRequestId: 1, title: 'Test PR 1' }, 181 | { pullRequestId: 2, title: 'Test PR 2' }, 182 | ]; 183 | 184 | // Setup mock connection 185 | const mockGitApi = { 186 | getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), 187 | }; 188 | 189 | const mockConnection: any = { 190 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 191 | }; 192 | 193 | // Call the function with minimal parameters (no top or skip) 194 | const projectId = 'test-project'; 195 | const repositoryId = 'test-repo'; 196 | const options = { projectId, repositoryId }; 197 | 198 | const result = await listPullRequests( 199 | mockConnection as WebApi, 200 | projectId, 201 | repositoryId, 202 | options, 203 | ); 204 | 205 | // Verify default values were used 206 | expect(mockGitApi.getPullRequests).toHaveBeenCalledWith( 207 | repositoryId, 208 | {}, 209 | projectId, 210 | undefined, // maxCommentLength 211 | 0, // default skip 212 | 10, // default top 213 | ); 214 | 215 | expect(result.count).toBe(2); 216 | expect(result.value).toEqual(mockPullRequests); 217 | }); 218 | 219 | test('should add warning when hasMoreResults is true', async () => { 220 | // Create exactly 10 mock pull requests to trigger hasMoreResults 221 | const mockPullRequests = Array(10) 222 | .fill(0) 223 | .map((_, i) => ({ 224 | pullRequestId: i + 1, 225 | title: `Test PR ${i + 1}`, 226 | })); 227 | 228 | // Setup mock connection 229 | const mockGitApi = { 230 | getPullRequests: jest.fn().mockResolvedValue(mockPullRequests), 231 | }; 232 | 233 | const mockConnection: any = { 234 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 235 | }; 236 | 237 | // Call with top=10 to match the number of results 238 | const projectId = 'test-project'; 239 | const repositoryId = 'test-repo'; 240 | const options = { 241 | projectId, 242 | repositoryId, 243 | top: 10, 244 | skip: 5, 245 | }; 246 | 247 | const result = await listPullRequests( 248 | mockConnection as WebApi, 249 | projectId, 250 | repositoryId, 251 | options, 252 | ); 253 | 254 | // Verify hasMoreResults is true and warning is set 255 | expect(result.hasMoreResults).toBe(true); 256 | expect(result.warning).toBe( 257 | "Results limited to 10 items. Use 'skip: 15' to get the next page.", 258 | ); 259 | }); 260 | }); 261 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { updatePullRequest } from './feature'; 2 | import { AzureDevOpsClient } from '../../../shared/auth/client-factory'; 3 | import { AzureDevOpsError } from '../../../shared/errors'; 4 | 5 | // Mock the AzureDevOpsClient 6 | jest.mock('../../../shared/auth/client-factory'); 7 | 8 | describe('updatePullRequest', () => { 9 | const mockGetPullRequestById = jest.fn(); 10 | const mockUpdatePullRequest = jest.fn(); 11 | const mockUpdateWorkItem = jest.fn(); 12 | const mockGetWorkItem = jest.fn(); 13 | 14 | // Mock Git API 15 | const mockGitApi = { 16 | getPullRequestById: mockGetPullRequestById, 17 | updatePullRequest: mockUpdatePullRequest, 18 | }; 19 | 20 | // Mock Work Item Tracking API 21 | const mockWorkItemTrackingApi = { 22 | updateWorkItem: mockUpdateWorkItem, 23 | getWorkItem: mockGetWorkItem, 24 | }; 25 | 26 | // Mock connection 27 | const mockConnection = { 28 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 29 | getWorkItemTrackingApi: jest 30 | .fn() 31 | .mockResolvedValue(mockWorkItemTrackingApi), 32 | }; 33 | 34 | const mockAzureDevopsClient = { 35 | getWebApiClient: jest.fn().mockResolvedValue(mockConnection), 36 | // ...other properties if needed 37 | }; 38 | 39 | beforeEach(() => { 40 | jest.clearAllMocks(); 41 | (AzureDevOpsClient as unknown as jest.Mock).mockImplementation( 42 | () => mockAzureDevopsClient, 43 | ); 44 | }); 45 | 46 | it('should throw error when pull request does not exist', async () => { 47 | mockGetPullRequestById.mockResolvedValueOnce(null); 48 | 49 | await expect( 50 | updatePullRequest({ 51 | projectId: 'project-1', 52 | repositoryId: 'repo1', 53 | pullRequestId: 123, 54 | }), 55 | ).rejects.toThrow(AzureDevOpsError); 56 | }); 57 | 58 | it('should update the pull request title and description', async () => { 59 | mockGetPullRequestById.mockResolvedValueOnce({ 60 | repository: { id: 'repo1' }, 61 | }); 62 | 63 | mockUpdatePullRequest.mockResolvedValueOnce({ 64 | title: 'Updated Title', 65 | description: 'Updated Description', 66 | }); 67 | 68 | const result = await updatePullRequest({ 69 | projectId: 'project-1', 70 | repositoryId: 'repo1', 71 | pullRequestId: 123, 72 | title: 'Updated Title', 73 | description: 'Updated Description', 74 | }); 75 | 76 | expect(mockUpdatePullRequest).toHaveBeenCalledWith( 77 | { 78 | title: 'Updated Title', 79 | description: 'Updated Description', 80 | }, 81 | 'repo1', 82 | 123, 83 | 'project-1', 84 | ); 85 | 86 | expect(result).toEqual({ 87 | title: 'Updated Title', 88 | description: 'Updated Description', 89 | }); 90 | }); 91 | 92 | it('should update the pull request status when status is provided', async () => { 93 | mockGetPullRequestById.mockResolvedValueOnce({ 94 | repository: { id: 'repo1' }, 95 | }); 96 | 97 | mockUpdatePullRequest.mockResolvedValueOnce({ 98 | status: 2, // Abandoned 99 | }); 100 | 101 | const result = await updatePullRequest({ 102 | projectId: 'project-1', 103 | repositoryId: 'repo1', 104 | pullRequestId: 123, 105 | status: 'abandoned', 106 | }); 107 | 108 | expect(mockUpdatePullRequest).toHaveBeenCalledWith( 109 | { 110 | status: 2, // Abandoned value 111 | }, 112 | 'repo1', 113 | 123, 114 | 'project-1', 115 | ); 116 | 117 | expect(result).toEqual({ 118 | status: 2, // Abandoned 119 | }); 120 | }); 121 | 122 | it('should throw error for invalid status', async () => { 123 | mockGetPullRequestById.mockResolvedValueOnce({ 124 | repository: { id: 'repo1' }, 125 | }); 126 | 127 | await expect( 128 | updatePullRequest({ 129 | projectId: 'project-1', 130 | repositoryId: 'repo1', 131 | pullRequestId: 123, 132 | status: 'invalid-status' as any, 133 | }), 134 | ).rejects.toThrow(AzureDevOpsError); 135 | }); 136 | 137 | it('should update the pull request draft status', async () => { 138 | mockGetPullRequestById.mockResolvedValueOnce({ 139 | repository: { id: 'repo1' }, 140 | }); 141 | 142 | mockUpdatePullRequest.mockResolvedValueOnce({ 143 | isDraft: true, 144 | }); 145 | 146 | const result = await updatePullRequest({ 147 | projectId: 'project-1', 148 | repositoryId: 'repo1', 149 | pullRequestId: 123, 150 | isDraft: true, 151 | }); 152 | 153 | expect(mockUpdatePullRequest).toHaveBeenCalledWith( 154 | { 155 | isDraft: true, 156 | }, 157 | 'repo1', 158 | 123, 159 | 'project-1', 160 | ); 161 | 162 | expect(result).toEqual({ 163 | isDraft: true, 164 | }); 165 | }); 166 | 167 | it('should include additionalProperties in the update', async () => { 168 | mockGetPullRequestById.mockResolvedValueOnce({ 169 | repository: { id: 'repo1' }, 170 | }); 171 | 172 | mockUpdatePullRequest.mockResolvedValueOnce({ 173 | title: 'Title', 174 | customProperty: 'custom value', 175 | }); 176 | 177 | const result = await updatePullRequest({ 178 | projectId: 'project-1', 179 | repositoryId: 'repo1', 180 | pullRequestId: 123, 181 | additionalProperties: { 182 | customProperty: 'custom value', 183 | }, 184 | }); 185 | 186 | expect(mockUpdatePullRequest).toHaveBeenCalledWith( 187 | { 188 | customProperty: 'custom value', 189 | }, 190 | 'repo1', 191 | 123, 192 | 'project-1', 193 | ); 194 | 195 | expect(result).toEqual({ 196 | title: 'Title', 197 | customProperty: 'custom value', 198 | }); 199 | }); 200 | 201 | it('should handle work item links', async () => { 202 | // Define the artifactId that will be used 203 | const artifactId = 'vstfs:///Git/PullRequestId/project-1/repo1/123'; 204 | 205 | mockGetPullRequestById.mockResolvedValueOnce({ 206 | repository: { id: 'repo1' }, 207 | artifactId: artifactId, // Add the artifactId to the mock response 208 | }); 209 | 210 | mockUpdatePullRequest.mockResolvedValueOnce({ 211 | pullRequestId: 123, 212 | repository: { id: 'repo1' }, 213 | artifactId: artifactId, 214 | }); 215 | 216 | // Mocks for work items to remove 217 | mockGetWorkItem.mockResolvedValueOnce({ 218 | relations: [ 219 | { 220 | rel: 'ArtifactLink', 221 | url: artifactId, // Use the same artifactId here 222 | attributes: { 223 | name: 'Pull Request', 224 | }, 225 | }, 226 | ], 227 | }); 228 | 229 | mockGetWorkItem.mockResolvedValueOnce({ 230 | relations: [ 231 | { 232 | rel: 'ArtifactLink', 233 | url: artifactId, // Use the same artifactId here 234 | attributes: { 235 | name: 'Pull Request', 236 | }, 237 | }, 238 | ], 239 | }); 240 | 241 | await updatePullRequest({ 242 | projectId: 'project-1', 243 | repositoryId: 'repo1', 244 | pullRequestId: 123, 245 | addWorkItemIds: [456, 789], 246 | removeWorkItemIds: [101, 202], 247 | }); 248 | 249 | // Check that updateWorkItem was called for adding work items 250 | expect(mockUpdateWorkItem).toHaveBeenCalledTimes(4); // 2 for add, 2 for remove 251 | expect(mockUpdateWorkItem).toHaveBeenCalledWith( 252 | null, 253 | [ 254 | { 255 | op: 'add', 256 | path: '/relations/-', 257 | value: { 258 | rel: 'ArtifactLink', 259 | url: 'vstfs:///Git/PullRequestId/project-1/repo1/123', 260 | attributes: { 261 | name: 'Pull Request', 262 | }, 263 | }, 264 | }, 265 | ], 266 | 456, 267 | ); 268 | 269 | // Check for removing work items 270 | expect(mockUpdateWorkItem).toHaveBeenCalledWith( 271 | null, 272 | [ 273 | { 274 | op: 'remove', 275 | path: '/relations/0', 276 | }, 277 | ], 278 | 101, 279 | ); 280 | }); 281 | 282 | it('should wrap unexpected errors in a friendly error message', async () => { 283 | mockGetPullRequestById.mockRejectedValueOnce(new Error('Unexpected')); 284 | 285 | await expect( 286 | updatePullRequest({ 287 | projectId: 'project-1', 288 | repositoryId: 'repo1', 289 | pullRequestId: 123, 290 | }), 291 | ).rejects.toThrow(AzureDevOpsError); 292 | }); 293 | }); 294 | ``` -------------------------------------------------------------------------------- /project-management/planning/azure-identity-authentication-design.md: -------------------------------------------------------------------------------- ```markdown 1 | # Azure Identity Authentication for Azure DevOps MCP Server 2 | 3 | This document outlines the implementation approach for adding Azure Identity authentication support to the Azure DevOps MCP Server. 4 | 5 | ## Overview 6 | 7 | The Azure DevOps MCP Server currently supports Personal Access Token (PAT) authentication. This enhancement will add support for Azure Identity authentication methods, specifically DefaultAzureCredential and AzureCliCredential, to provide more flexible authentication options for different environments. 8 | 9 | ## Azure Identity SDK 10 | 11 | The `@azure/identity` package provides various credential types for authenticating with Azure services. For our implementation, we will focus on the following credential types: 12 | 13 | ### DefaultAzureCredential 14 | 15 | `DefaultAzureCredential` provides a simplified authentication experience by trying multiple credential types in sequence: 16 | 17 | 1. Environment variables (EnvironmentCredential) 18 | 2. Managed Identity (ManagedIdentityCredential) 19 | 3. Azure CLI (AzureCliCredential) 20 | 4. Visual Studio Code (VisualStudioCodeCredential) 21 | 5. Azure PowerShell (AzurePowerShellCredential) 22 | 6. Interactive Browser (InteractiveBrowserCredential) - optional, disabled by default 23 | 24 | This makes it ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes. 25 | 26 | ### AzureCliCredential 27 | 28 | `AzureCliCredential` authenticates using the Azure CLI's logged-in account. It requires the Azure CLI to be installed and the user to be logged in (`az login`). This is particularly useful for local development scenarios where developers are already using the Azure CLI. 29 | 30 | ## Implementation Approach 31 | 32 | ### 1. Authentication Abstraction Layer 33 | 34 | Create an abstraction layer for authentication that supports both PAT and Azure Identity methods: 35 | 36 | ```typescript 37 | // src/api/auth.ts 38 | export interface AuthProvider { 39 | getConnection(): Promise<WebApi>; 40 | isAuthenticated(): Promise<boolean>; 41 | } 42 | 43 | export class PatAuthProvider implements AuthProvider { 44 | // Existing PAT authentication implementation 45 | } 46 | 47 | export class AzureIdentityAuthProvider implements AuthProvider { 48 | // New Azure Identity authentication implementation 49 | } 50 | ``` 51 | 52 | ### 2. Authentication Factory 53 | 54 | Implement a factory pattern to create the appropriate authentication provider based on configuration: 55 | 56 | ```typescript 57 | // src/api/auth.ts 58 | export enum AuthMethod { 59 | PAT = 'pat', 60 | AZURE_IDENTITY = 'azure-identity', 61 | } 62 | 63 | export function createAuthProvider(config: AzureDevOpsConfig): AuthProvider { 64 | switch (config.authMethod) { 65 | case AuthMethod.AZURE_IDENTITY: 66 | return new AzureIdentityAuthProvider(config); 67 | case AuthMethod.PAT: 68 | default: 69 | return new PatAuthProvider(config); 70 | } 71 | } 72 | ``` 73 | 74 | ### 3. Azure Identity Authentication Provider 75 | 76 | Implement the Azure Identity authentication provider: 77 | 78 | ```typescript 79 | // src/api/auth.ts 80 | export class AzureIdentityAuthProvider implements AuthProvider { 81 | private config: AzureDevOpsConfig; 82 | private connectionPromise: Promise<WebApi> | null = null; 83 | 84 | constructor(config: AzureDevOpsConfig) { 85 | this.config = config; 86 | } 87 | 88 | async getConnection(): Promise<WebApi> { 89 | if (!this.connectionPromise) { 90 | this.connectionPromise = this.createConnection(); 91 | } 92 | return this.connectionPromise; 93 | } 94 | 95 | private async createConnection(): Promise<WebApi> { 96 | try { 97 | // Azure DevOps resource ID for token scope 98 | const azureDevOpsResourceId = '499b84ac-1321-427f-aa17-267ca6975798'; 99 | 100 | // Create credential based on configuration 101 | const credential = this.createCredential(); 102 | 103 | // Get token for Azure DevOps 104 | const token = await credential.getToken( 105 | `${azureDevOpsResourceId}/.default`, 106 | ); 107 | 108 | if (!token) { 109 | throw new AzureDevOpsAuthenticationError( 110 | 'Failed to acquire token from Azure Identity', 111 | ); 112 | } 113 | 114 | // Create auth handler with token 115 | const authHandler = new BearerCredentialHandler(token.token); 116 | 117 | // Create WebApi client 118 | const connection = new WebApi(this.config.organizationUrl, authHandler); 119 | 120 | // Test the connection 121 | await connection.getLocationsApi(); 122 | 123 | return connection; 124 | } catch (error) { 125 | throw new AzureDevOpsAuthenticationError( 126 | `Failed to authenticate with Azure Identity: ${error instanceof Error ? error.message : String(error)}`, 127 | ); 128 | } 129 | } 130 | 131 | private createCredential(): TokenCredential { 132 | if (this.config.azureIdentityOptions?.useAzureCliCredential) { 133 | return new AzureCliCredential(); 134 | } 135 | 136 | // Default to DefaultAzureCredential 137 | return new DefaultAzureCredential(); 138 | } 139 | 140 | async isAuthenticated(): Promise<boolean> { 141 | try { 142 | await this.getConnection(); 143 | return true; 144 | } catch { 145 | return false; 146 | } 147 | } 148 | } 149 | ``` 150 | 151 | ### 4. Configuration Updates 152 | 153 | Update the configuration interface to support specifying the authentication method: 154 | 155 | ```typescript 156 | // src/types/config.ts 157 | export interface AzureDevOpsConfig { 158 | // Existing properties 159 | organizationUrl: string; 160 | personalAccessToken?: string; 161 | defaultProject?: string; 162 | apiVersion?: string; 163 | 164 | // New properties 165 | authMethod?: AuthMethod; 166 | azureIdentityOptions?: { 167 | useAzureCliCredential?: boolean; 168 | // Other Azure Identity options as needed 169 | }; 170 | } 171 | ``` 172 | 173 | ### 5. Environment Variable Updates 174 | 175 | Update the environment variable handling in `index.ts`: 176 | 177 | ```typescript 178 | // src/index.ts 179 | const config: AzureDevOpsConfig = { 180 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', 181 | personalAccessToken: process.env.AZURE_DEVOPS_PAT, 182 | defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, 183 | apiVersion: process.env.AZURE_DEVOPS_API_VERSION, 184 | authMethod: 185 | (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthMethod) || AuthMethod.PAT, 186 | azureIdentityOptions: { 187 | useAzureCliCredential: 188 | process.env.AZURE_DEVOPS_USE_CLI_CREDENTIAL === 'true', 189 | }, 190 | }; 191 | ``` 192 | 193 | ### 6. Client Updates 194 | 195 | Update the `AzureDevOpsClient` class to use the authentication provider: 196 | 197 | ```typescript 198 | // src/api/client.ts 199 | export class AzureDevOpsClient { 200 | private authProvider: AuthProvider; 201 | 202 | constructor(config: AzureDevOpsConfig) { 203 | this.authProvider = createAuthProvider(config); 204 | } 205 | 206 | private async getClient(): Promise<WebApi> { 207 | return this.authProvider.getConnection(); 208 | } 209 | 210 | // Rest of the class remains the same 211 | } 212 | ``` 213 | 214 | ## Error Handling 215 | 216 | Implement proper error handling for Azure Identity authentication failures: 217 | 218 | ```typescript 219 | // src/common/errors.ts 220 | export class AzureIdentityAuthenticationError extends AzureDevOpsAuthenticationError { 221 | constructor(message: string) { 222 | super(`Azure Identity Authentication Error: ${message}`); 223 | } 224 | } 225 | ``` 226 | 227 | ## Configuration Examples 228 | 229 | ### PAT Authentication 230 | 231 | ```env 232 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org 233 | AZURE_DEVOPS_PAT=your-pat 234 | AZURE_DEVOPS_AUTH_METHOD=pat 235 | ``` 236 | 237 | ### DefaultAzureCredential Authentication 238 | 239 | ```env 240 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org 241 | AZURE_DEVOPS_AUTH_METHOD=azure-identity 242 | # Optional environment variables for specific credential types 243 | AZURE_TENANT_ID=your-tenant-id 244 | AZURE_CLIENT_ID=your-client-id 245 | AZURE_CLIENT_SECRET=your-client-secret 246 | ``` 247 | 248 | ### AzureCliCredential Authentication 249 | 250 | ```env 251 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-org 252 | AZURE_DEVOPS_AUTH_METHOD=azure-cli 253 | ``` ``` -------------------------------------------------------------------------------- /src/features/work-items/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { isWorkItemsRequest, handleWorkItemsRequest } from './'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { WebApi } from 'azure-devops-node-api'; 4 | import * as workItemModule from './'; 5 | 6 | // Mock the imported modules 7 | jest.mock('./get-work-item', () => ({ 8 | getWorkItem: jest.fn(), 9 | })); 10 | 11 | jest.mock('./list-work-items', () => ({ 12 | listWorkItems: jest.fn(), 13 | })); 14 | 15 | jest.mock('./create-work-item', () => ({ 16 | createWorkItem: jest.fn(), 17 | })); 18 | 19 | jest.mock('./update-work-item', () => ({ 20 | updateWorkItem: jest.fn(), 21 | })); 22 | 23 | jest.mock('./manage-work-item-link', () => ({ 24 | manageWorkItemLink: jest.fn(), 25 | })); 26 | 27 | // Helper function to create a valid CallToolRequest object 28 | const createCallToolRequest = (name: string, args: any): CallToolRequest => { 29 | return { 30 | method: 'tools/call', 31 | params: { 32 | name, 33 | arguments: args, 34 | }, 35 | } as unknown as CallToolRequest; 36 | }; 37 | 38 | describe('Work Items Request Handlers', () => { 39 | describe('isWorkItemsRequest', () => { 40 | it('should return true for work items requests', () => { 41 | const workItemsRequests = [ 42 | 'get_work_item', 43 | 'list_work_items', 44 | 'create_work_item', 45 | 'update_work_item', 46 | 'manage_work_item_link', 47 | ]; 48 | 49 | workItemsRequests.forEach((name) => { 50 | const request = createCallToolRequest(name, {}); 51 | 52 | expect(isWorkItemsRequest(request)).toBe(true); 53 | }); 54 | }); 55 | 56 | it('should return false for non-work items requests', () => { 57 | const request = createCallToolRequest('get_project', {}); 58 | 59 | expect(isWorkItemsRequest(request)).toBe(false); 60 | }); 61 | }); 62 | 63 | describe('handleWorkItemsRequest', () => { 64 | let mockConnection: WebApi; 65 | 66 | beforeEach(() => { 67 | mockConnection = {} as WebApi; 68 | 69 | // Setup mock for schema validation - with correct return types 70 | jest 71 | .spyOn(workItemModule.GetWorkItemSchema, 'parse') 72 | .mockImplementation(() => { 73 | return { workItemId: 123, expand: undefined }; 74 | }); 75 | 76 | jest 77 | .spyOn(workItemModule.ListWorkItemsSchema, 'parse') 78 | .mockImplementation(() => { 79 | return { projectId: 'myProject' }; 80 | }); 81 | 82 | jest 83 | .spyOn(workItemModule.CreateWorkItemSchema, 'parse') 84 | .mockImplementation(() => { 85 | return { 86 | projectId: 'myProject', 87 | workItemType: 'Task', 88 | title: 'New Task', 89 | }; 90 | }); 91 | 92 | jest 93 | .spyOn(workItemModule.UpdateWorkItemSchema, 'parse') 94 | .mockImplementation(() => { 95 | return { 96 | workItemId: 123, 97 | title: 'Updated Title', 98 | }; 99 | }); 100 | 101 | jest 102 | .spyOn(workItemModule.ManageWorkItemLinkSchema, 'parse') 103 | .mockImplementation(() => { 104 | return { 105 | sourceWorkItemId: 123, 106 | targetWorkItemId: 456, 107 | operation: 'add' as 'add' | 'remove' | 'update', 108 | relationType: 'System.LinkTypes.Hierarchy-Forward', 109 | }; 110 | }); 111 | 112 | // Setup mocks for feature functions 113 | jest.spyOn(workItemModule, 'getWorkItem').mockResolvedValue({ id: 123 }); 114 | jest 115 | .spyOn(workItemModule, 'listWorkItems') 116 | .mockResolvedValue([{ id: 123 }, { id: 456 }]); 117 | jest 118 | .spyOn(workItemModule, 'createWorkItem') 119 | .mockResolvedValue({ id: 789 }); 120 | jest 121 | .spyOn(workItemModule, 'updateWorkItem') 122 | .mockResolvedValue({ id: 123 }); 123 | jest 124 | .spyOn(workItemModule, 'manageWorkItemLink') 125 | .mockResolvedValue({ id: 123 }); 126 | }); 127 | 128 | afterEach(() => { 129 | jest.resetAllMocks(); 130 | }); 131 | 132 | it('should handle get_work_item requests', async () => { 133 | const request = createCallToolRequest('get_work_item', { 134 | workItemId: 123, 135 | }); 136 | 137 | const result = await handleWorkItemsRequest(mockConnection, request); 138 | 139 | expect(workItemModule.GetWorkItemSchema.parse).toHaveBeenCalledWith({ 140 | workItemId: 123, 141 | }); 142 | expect(workItemModule.getWorkItem).toHaveBeenCalledWith( 143 | mockConnection, 144 | 123, 145 | undefined, 146 | ); 147 | expect(result).toEqual({ 148 | content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], 149 | }); 150 | }); 151 | 152 | it('should handle list_work_items requests', async () => { 153 | const request = createCallToolRequest('list_work_items', { 154 | projectId: 'myProject', 155 | }); 156 | 157 | const result = await handleWorkItemsRequest(mockConnection, request); 158 | 159 | expect(workItemModule.ListWorkItemsSchema.parse).toHaveBeenCalledWith({ 160 | projectId: 'myProject', 161 | }); 162 | expect(workItemModule.listWorkItems).toHaveBeenCalled(); 163 | expect(result).toEqual({ 164 | content: [ 165 | { 166 | type: 'text', 167 | text: JSON.stringify([{ id: 123 }, { id: 456 }], null, 2), 168 | }, 169 | ], 170 | }); 171 | }); 172 | 173 | it('should handle create_work_item requests', async () => { 174 | const request = createCallToolRequest('create_work_item', { 175 | projectId: 'myProject', 176 | workItemType: 'Task', 177 | title: 'New Task', 178 | }); 179 | 180 | const result = await handleWorkItemsRequest(mockConnection, request); 181 | 182 | expect(workItemModule.CreateWorkItemSchema.parse).toHaveBeenCalledWith({ 183 | projectId: 'myProject', 184 | workItemType: 'Task', 185 | title: 'New Task', 186 | }); 187 | expect(workItemModule.createWorkItem).toHaveBeenCalled(); 188 | expect(result).toEqual({ 189 | content: [{ type: 'text', text: JSON.stringify({ id: 789 }, null, 2) }], 190 | }); 191 | }); 192 | 193 | it('should handle update_work_item requests', async () => { 194 | const request = createCallToolRequest('update_work_item', { 195 | workItemId: 123, 196 | title: 'Updated Title', 197 | }); 198 | 199 | const result = await handleWorkItemsRequest(mockConnection, request); 200 | 201 | expect(workItemModule.UpdateWorkItemSchema.parse).toHaveBeenCalledWith({ 202 | workItemId: 123, 203 | title: 'Updated Title', 204 | }); 205 | expect(workItemModule.updateWorkItem).toHaveBeenCalled(); 206 | expect(result).toEqual({ 207 | content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], 208 | }); 209 | }); 210 | 211 | it('should handle manage_work_item_link requests', async () => { 212 | const request = createCallToolRequest('manage_work_item_link', { 213 | sourceWorkItemId: 123, 214 | targetWorkItemId: 456, 215 | operation: 'add', 216 | relationType: 'System.LinkTypes.Hierarchy-Forward', 217 | }); 218 | 219 | const result = await handleWorkItemsRequest(mockConnection, request); 220 | 221 | expect( 222 | workItemModule.ManageWorkItemLinkSchema.parse, 223 | ).toHaveBeenCalledWith({ 224 | sourceWorkItemId: 123, 225 | targetWorkItemId: 456, 226 | operation: 'add', 227 | relationType: 'System.LinkTypes.Hierarchy-Forward', 228 | }); 229 | expect(workItemModule.manageWorkItemLink).toHaveBeenCalled(); 230 | expect(result).toEqual({ 231 | content: [{ type: 'text', text: JSON.stringify({ id: 123 }, null, 2) }], 232 | }); 233 | }); 234 | 235 | it('should throw an error for unknown work items tools', async () => { 236 | const request = createCallToolRequest('unknown_tool', {}); 237 | 238 | await expect( 239 | handleWorkItemsRequest(mockConnection, request), 240 | ).rejects.toThrow('Unknown work items tool: unknown_tool'); 241 | }); 242 | }); 243 | }); 244 | ``` -------------------------------------------------------------------------------- /src/features/work-items/get-work-item/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { getWorkItem } from './feature'; 3 | import { 4 | getTestConnection, 5 | shouldSkipIntegrationTest, 6 | } from '../__test__/test-helpers'; 7 | 8 | import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; 9 | import { createWorkItem } from '../create-work-item/feature'; 10 | import { manageWorkItemLink } from '../manage-work-item-link/feature'; 11 | import { CreateWorkItemOptions } from '../types'; 12 | 13 | describe('getWorkItem integration', () => { 14 | let connection: WebApi | null = null; 15 | let testWorkItemId: number | null = null; 16 | let linkedWorkItemId: number | null = null; 17 | let projectName: string; 18 | 19 | beforeAll(async () => { 20 | // Get a real connection using environment variables 21 | connection = await getTestConnection(); 22 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; 23 | 24 | // Skip setup if integration tests should be skipped 25 | if (shouldSkipIntegrationTest() || !connection) { 26 | return; 27 | } 28 | 29 | try { 30 | // Create a test work item 31 | const uniqueTitle = `Test Work Item ${new Date().toISOString()}`; 32 | const options: CreateWorkItemOptions = { 33 | title: uniqueTitle, 34 | description: 'Test work item for get-work-item integration tests', 35 | }; 36 | 37 | const testWorkItem = await createWorkItem( 38 | connection, 39 | projectName, 40 | 'Task', 41 | options, 42 | ); 43 | 44 | // Create another work item to link to the first one 45 | const linkedItemOptions: CreateWorkItemOptions = { 46 | title: `Linked Work Item ${new Date().toISOString()}`, 47 | description: 'Linked work item for get-work-item integration tests', 48 | }; 49 | 50 | const linkedWorkItem = await createWorkItem( 51 | connection, 52 | projectName, 53 | 'Task', 54 | linkedItemOptions, 55 | ); 56 | 57 | if (testWorkItem?.id && linkedWorkItem?.id) { 58 | testWorkItemId = testWorkItem.id; 59 | linkedWorkItemId = linkedWorkItem.id; 60 | 61 | // Create a link between the two work items 62 | await manageWorkItemLink(connection, projectName, { 63 | sourceWorkItemId: testWorkItemId, 64 | targetWorkItemId: linkedWorkItemId, 65 | operation: 'add', 66 | relationType: 'System.LinkTypes.Related', 67 | comment: 'Link created for get-work-item integration tests', 68 | }); 69 | } 70 | } catch (error) { 71 | console.error('Failed to create test work items:', error); 72 | } 73 | }); 74 | 75 | test('should retrieve a real work item from Azure DevOps with default expand=all', async () => { 76 | // Skip if no connection is available 77 | if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { 78 | return; 79 | } 80 | 81 | // Act - get work item by ID 82 | const result = await getWorkItem(connection, testWorkItemId); 83 | 84 | // Assert 85 | expect(result).toBeDefined(); 86 | expect(result.id).toBe(testWorkItemId); 87 | 88 | // Verify expanded fields and data are present 89 | expect(result.fields).toBeDefined(); 90 | expect(result._links).toBeDefined(); 91 | 92 | // With expand=all and a linked item, relations should be defined 93 | expect(result.relations).toBeDefined(); 94 | 95 | if (result.fields) { 96 | // Verify common fields that should be present with expand=all 97 | expect(result.fields['System.Title']).toBeDefined(); 98 | expect(result.fields['System.State']).toBeDefined(); 99 | expect(result.fields['System.CreatedDate']).toBeDefined(); 100 | expect(result.fields['System.ChangedDate']).toBeDefined(); 101 | } 102 | }); 103 | 104 | test('should retrieve work item with expanded relations', async () => { 105 | // Skip if no connection is available 106 | if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { 107 | return; 108 | } 109 | 110 | // Act - get work item with relations expansion 111 | const result = await getWorkItem(connection, testWorkItemId, 'relations'); 112 | 113 | // Assert 114 | expect(result).toBeDefined(); 115 | expect(result.id).toBe(testWorkItemId); 116 | 117 | // When using expand=relations on a work item with links, relations should be defined 118 | expect(result.relations).toBeDefined(); 119 | 120 | // Verify we can access the related work item 121 | if (result.relations && result.relations.length > 0) { 122 | const relation = result.relations[0]; 123 | expect(relation.rel).toBe('System.LinkTypes.Related'); 124 | expect(relation.url).toContain(linkedWorkItemId?.toString()); 125 | } 126 | 127 | // Verify fields exist 128 | expect(result.fields).toBeDefined(); 129 | if (result.fields) { 130 | expect(result.fields['System.Title']).toBeDefined(); 131 | } 132 | }); 133 | 134 | test('should retrieve work item with minimal fields when using expand=none', async () => { 135 | if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { 136 | return; 137 | } 138 | 139 | // Act - get work item with no expansion 140 | const result = await getWorkItem(connection, testWorkItemId, 'none'); 141 | 142 | // Assert 143 | expect(result).toBeDefined(); 144 | expect(result.id).toBe(testWorkItemId); 145 | expect(result.fields).toBeDefined(); 146 | 147 | // With expand=none, we should still get _links but no relations 148 | // The Azure DevOps API still returns _links even with expand=none 149 | expect(result.relations).toBeUndefined(); 150 | }); 151 | 152 | test('should throw AzureDevOpsResourceNotFoundError for non-existent work item', async () => { 153 | if (shouldSkipIntegrationTest() || !connection) { 154 | return; 155 | } 156 | 157 | // Use a very large ID that's unlikely to exist 158 | const nonExistentId = 999999999; 159 | 160 | // Assert that it throws the correct error 161 | await expect(getWorkItem(connection, nonExistentId)).rejects.toThrow( 162 | AzureDevOpsResourceNotFoundError, 163 | ); 164 | }); 165 | 166 | test('should include all possible fields with null values for empty fields', async () => { 167 | // Skip if no connection is available 168 | if (shouldSkipIntegrationTest() || !connection || !testWorkItemId) { 169 | return; 170 | } 171 | 172 | // Act - get work item by ID 173 | const result = await getWorkItem(connection, testWorkItemId); 174 | 175 | // Assert 176 | expect(result).toBeDefined(); 177 | expect(result.fields).toBeDefined(); 178 | 179 | if (result.fields) { 180 | // Get a direct connection to WorkItemTrackingApi to fetch field info for comparison 181 | const witApi = await connection.getWorkItemTrackingApi(); 182 | const projectName = result.fields['System.TeamProject']; 183 | const workItemType = result.fields['System.WorkItemType']; 184 | 185 | expect(projectName).toBeDefined(); 186 | expect(workItemType).toBeDefined(); 187 | 188 | if (projectName && workItemType) { 189 | // Get all possible field references for this work item type 190 | const allFields = await witApi.getWorkItemTypeFieldsWithReferences( 191 | projectName.toString(), 192 | workItemType.toString(), 193 | ); 194 | 195 | // Check that all fields from the reference are present in the result 196 | // Some might be null, but they should exist in the fields object 197 | for (const field of allFields) { 198 | if (field.referenceName) { 199 | expect(Object.keys(result.fields)).toContain(field.referenceName); 200 | } 201 | } 202 | 203 | // There should be at least one field with a null value 204 | // (This is a probabilistic test but very likely to pass since work items 205 | // typically have many optional fields that aren't filled in) 206 | const hasNullField = Object.values(result.fields).some( 207 | (value) => value === null, 208 | ); 209 | expect(hasNullField).toBe(true); 210 | } 211 | } 212 | }); 213 | }); 214 | ``` -------------------------------------------------------------------------------- /src/features/wikis/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isWikisRequest, handleWikisRequest } from './index'; 4 | import { getWikis, GetWikisSchema } from './get-wikis'; 5 | import { getWikiPage, GetWikiPageSchema } from './get-wiki-page'; 6 | import { createWiki, CreateWikiSchema, WikiType } from './create-wiki'; 7 | import { updateWikiPage, UpdateWikiPageSchema } from './update-wiki-page'; 8 | 9 | // Mock the imported modules 10 | jest.mock('./get-wikis', () => ({ 11 | getWikis: jest.fn(), 12 | GetWikisSchema: { 13 | parse: jest.fn(), 14 | }, 15 | })); 16 | 17 | jest.mock('./get-wiki-page', () => ({ 18 | getWikiPage: jest.fn(), 19 | GetWikiPageSchema: { 20 | parse: jest.fn(), 21 | }, 22 | })); 23 | 24 | jest.mock('./create-wiki', () => ({ 25 | createWiki: jest.fn(), 26 | CreateWikiSchema: { 27 | parse: jest.fn(), 28 | }, 29 | WikiType: { 30 | ProjectWiki: 'projectWiki', 31 | CodeWiki: 'codeWiki', 32 | }, 33 | })); 34 | 35 | jest.mock('./update-wiki-page', () => ({ 36 | updateWikiPage: jest.fn(), 37 | UpdateWikiPageSchema: { 38 | parse: jest.fn(), 39 | }, 40 | })); 41 | 42 | describe('Wikis Request Handlers', () => { 43 | const mockConnection = {} as WebApi; 44 | 45 | describe('isWikisRequest', () => { 46 | it('should return true for wikis requests', () => { 47 | const validTools = [ 48 | 'get_wikis', 49 | 'get_wiki_page', 50 | 'create_wiki', 51 | 'update_wiki_page', 52 | ]; 53 | validTools.forEach((tool) => { 54 | const request = { 55 | params: { name: tool, arguments: {} }, 56 | method: 'tools/call', 57 | } as CallToolRequest; 58 | expect(isWikisRequest(request)).toBe(true); 59 | }); 60 | }); 61 | 62 | it('should return false for non-wikis requests', () => { 63 | const request = { 64 | params: { name: 'list_projects', arguments: {} }, 65 | method: 'tools/call', 66 | } as CallToolRequest; 67 | expect(isWikisRequest(request)).toBe(false); 68 | }); 69 | }); 70 | 71 | describe('handleWikisRequest', () => { 72 | it('should handle get_wikis request', async () => { 73 | const mockWikis = [ 74 | { id: 'wiki1', name: 'Wiki 1' }, 75 | { id: 'wiki2', name: 'Wiki 2' }, 76 | ]; 77 | (getWikis as jest.Mock).mockResolvedValue(mockWikis); 78 | 79 | const request = { 80 | params: { 81 | name: 'get_wikis', 82 | arguments: { 83 | projectId: 'project1', 84 | }, 85 | }, 86 | method: 'tools/call', 87 | } as CallToolRequest; 88 | 89 | // Mock the arguments object after parsing 90 | (GetWikisSchema.parse as jest.Mock).mockReturnValue({ 91 | projectId: 'project1', 92 | }); 93 | 94 | const response = await handleWikisRequest(mockConnection, request); 95 | expect(response.content).toHaveLength(1); 96 | expect(JSON.parse(response.content[0].text as string)).toEqual(mockWikis); 97 | expect(getWikis).toHaveBeenCalledWith( 98 | mockConnection, 99 | expect.objectContaining({ 100 | projectId: 'project1', 101 | }), 102 | ); 103 | }); 104 | 105 | it('should handle get_wiki_page request', async () => { 106 | const mockWikiContent = '# Wiki Page\n\nThis is a wiki page content.'; 107 | (getWikiPage as jest.Mock).mockResolvedValue(mockWikiContent); 108 | 109 | const request = { 110 | params: { 111 | name: 'get_wiki_page', 112 | arguments: { 113 | projectId: 'project1', 114 | wikiId: 'wiki1', 115 | pagePath: '/Home', 116 | }, 117 | }, 118 | method: 'tools/call', 119 | } as CallToolRequest; 120 | 121 | // Mock the arguments object after parsing 122 | (GetWikiPageSchema.parse as jest.Mock).mockReturnValue({ 123 | projectId: 'project1', 124 | wikiId: 'wiki1', 125 | pagePath: '/Home', 126 | }); 127 | 128 | const response = await handleWikisRequest(mockConnection, request); 129 | expect(response.content).toHaveLength(1); 130 | expect(response.content[0].text as string).toEqual(mockWikiContent); 131 | expect(getWikiPage).toHaveBeenCalledWith( 132 | expect.objectContaining({ 133 | projectId: 'project1', 134 | wikiId: 'wiki1', 135 | pagePath: '/Home', 136 | }), 137 | ); 138 | }); 139 | 140 | it('should handle create_wiki request', async () => { 141 | const mockWiki = { id: 'wiki1', name: 'New Wiki' }; 142 | (createWiki as jest.Mock).mockResolvedValue(mockWiki); 143 | 144 | const request = { 145 | params: { 146 | name: 'create_wiki', 147 | arguments: { 148 | projectId: 'project1', 149 | name: 'New Wiki', 150 | type: WikiType.ProjectWiki, 151 | }, 152 | }, 153 | method: 'tools/call', 154 | } as CallToolRequest; 155 | 156 | // Mock the arguments object after parsing 157 | (CreateWikiSchema.parse as jest.Mock).mockReturnValue({ 158 | projectId: 'project1', 159 | name: 'New Wiki', 160 | type: WikiType.ProjectWiki, 161 | mappedPath: null, // Required field in the schema 162 | }); 163 | 164 | const response = await handleWikisRequest(mockConnection, request); 165 | expect(response.content).toHaveLength(1); 166 | expect(JSON.parse(response.content[0].text as string)).toEqual(mockWiki); 167 | expect(createWiki).toHaveBeenCalledWith( 168 | mockConnection, 169 | expect.objectContaining({ 170 | projectId: 'project1', 171 | name: 'New Wiki', 172 | type: WikiType.ProjectWiki, 173 | }), 174 | ); 175 | }); 176 | 177 | it('should handle update_wiki_page request', async () => { 178 | const mockUpdateResult = { id: 'page1', content: 'Updated content' }; 179 | (updateWikiPage as jest.Mock).mockResolvedValue(mockUpdateResult); 180 | 181 | const request = { 182 | params: { 183 | name: 'update_wiki_page', 184 | arguments: { 185 | projectId: 'project1', 186 | wikiId: 'wiki1', 187 | pagePath: '/Home', 188 | content: 'Updated content', 189 | comment: 'Update home page', 190 | }, 191 | }, 192 | method: 'tools/call', 193 | } as CallToolRequest; 194 | 195 | // Mock the arguments object after parsing 196 | (UpdateWikiPageSchema.parse as jest.Mock).mockReturnValue({ 197 | projectId: 'project1', 198 | wikiId: 'wiki1', 199 | pagePath: '/Home', 200 | content: 'Updated content', 201 | comment: 'Update home page', 202 | }); 203 | 204 | const response = await handleWikisRequest(mockConnection, request); 205 | expect(response.content).toHaveLength(1); 206 | expect(JSON.parse(response.content[0].text as string)).toEqual( 207 | mockUpdateResult, 208 | ); 209 | expect(updateWikiPage).toHaveBeenCalledWith( 210 | expect.objectContaining({ 211 | projectId: 'project1', 212 | wikiId: 'wiki1', 213 | pagePath: '/Home', 214 | content: 'Updated content', 215 | comment: 'Update home page', 216 | }), 217 | ); 218 | }); 219 | 220 | it('should throw error for unknown tool', async () => { 221 | const request = { 222 | params: { 223 | name: 'unknown_tool', 224 | arguments: {}, 225 | }, 226 | method: 'tools/call', 227 | } as CallToolRequest; 228 | 229 | await expect(handleWikisRequest(mockConnection, request)).rejects.toThrow( 230 | 'Unknown wikis tool', 231 | ); 232 | }); 233 | 234 | it('should propagate errors from wiki functions', async () => { 235 | const mockError = new Error('Test error'); 236 | (getWikis as jest.Mock).mockRejectedValue(mockError); 237 | 238 | const request = { 239 | params: { 240 | name: 'get_wikis', 241 | arguments: { 242 | projectId: 'project1', 243 | }, 244 | }, 245 | method: 'tools/call', 246 | } as CallToolRequest; 247 | 248 | // Mock the arguments object after parsing 249 | (GetWikisSchema.parse as jest.Mock).mockReturnValue({ 250 | projectId: 'project1', 251 | }); 252 | 253 | await expect(handleWikisRequest(mockConnection, request)).rejects.toThrow( 254 | mockError, 255 | ); 256 | }); 257 | }); 258 | }); 259 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/index.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { isPullRequestsRequest, handlePullRequestsRequest } from './index'; 4 | import { createPullRequest } from './create-pull-request'; 5 | import { listPullRequests } from './list-pull-requests'; 6 | import { getPullRequestComments } from './get-pull-request-comments'; 7 | import { addPullRequestComment } from './add-pull-request-comment'; 8 | import { AddPullRequestCommentSchema } from './schemas'; 9 | 10 | // Mock the imported modules 11 | jest.mock('./create-pull-request', () => ({ 12 | createPullRequest: jest.fn(), 13 | })); 14 | 15 | jest.mock('./list-pull-requests', () => ({ 16 | listPullRequests: jest.fn(), 17 | })); 18 | 19 | jest.mock('./get-pull-request-comments', () => ({ 20 | getPullRequestComments: jest.fn(), 21 | })); 22 | 23 | jest.mock('./add-pull-request-comment', () => ({ 24 | addPullRequestComment: jest.fn(), 25 | })); 26 | 27 | describe('Pull Requests Request Handlers', () => { 28 | const mockConnection = {} as WebApi; 29 | 30 | describe('isPullRequestsRequest', () => { 31 | it('should return true for pull requests tools', () => { 32 | const validTools = [ 33 | 'create_pull_request', 34 | 'list_pull_requests', 35 | 'get_pull_request_comments', 36 | 'add_pull_request_comment', 37 | ]; 38 | validTools.forEach((tool) => { 39 | const request = { 40 | params: { name: tool, arguments: {} }, 41 | method: 'tools/call', 42 | } as CallToolRequest; 43 | expect(isPullRequestsRequest(request)).toBe(true); 44 | }); 45 | }); 46 | 47 | it('should return false for non-pull requests tools', () => { 48 | const request = { 49 | params: { name: 'list_projects', arguments: {} }, 50 | method: 'tools/call', 51 | } as CallToolRequest; 52 | expect(isPullRequestsRequest(request)).toBe(false); 53 | }); 54 | }); 55 | 56 | describe('handlePullRequestsRequest', () => { 57 | it('should handle create_pull_request request', async () => { 58 | const mockPullRequest = { id: 1, title: 'Test PR' }; 59 | (createPullRequest as jest.Mock).mockResolvedValue(mockPullRequest); 60 | 61 | const request = { 62 | params: { 63 | name: 'create_pull_request', 64 | arguments: { 65 | repositoryId: 'test-repo', 66 | title: 'Test PR', 67 | sourceRefName: 'refs/heads/feature', 68 | targetRefName: 'refs/heads/main', 69 | }, 70 | }, 71 | method: 'tools/call', 72 | } as CallToolRequest; 73 | 74 | const response = await handlePullRequestsRequest(mockConnection, request); 75 | expect(response.content).toHaveLength(1); 76 | expect(JSON.parse(response.content[0].text as string)).toEqual( 77 | mockPullRequest, 78 | ); 79 | expect(createPullRequest).toHaveBeenCalledWith( 80 | mockConnection, 81 | expect.any(String), 82 | 'test-repo', 83 | expect.objectContaining({ 84 | title: 'Test PR', 85 | sourceRefName: 'refs/heads/feature', 86 | targetRefName: 'refs/heads/main', 87 | }), 88 | ); 89 | }); 90 | 91 | it('should handle list_pull_requests request', async () => { 92 | const mockPullRequests = { 93 | count: 2, 94 | value: [ 95 | { id: 1, title: 'PR 1' }, 96 | { id: 2, title: 'PR 2' }, 97 | ], 98 | hasMoreResults: false, 99 | }; 100 | (listPullRequests as jest.Mock).mockResolvedValue(mockPullRequests); 101 | 102 | const request = { 103 | params: { 104 | name: 'list_pull_requests', 105 | arguments: { 106 | repositoryId: 'test-repo', 107 | status: 'active', 108 | }, 109 | }, 110 | method: 'tools/call', 111 | } as CallToolRequest; 112 | 113 | const response = await handlePullRequestsRequest(mockConnection, request); 114 | expect(response.content).toHaveLength(1); 115 | expect(JSON.parse(response.content[0].text as string)).toEqual( 116 | mockPullRequests, 117 | ); 118 | expect(listPullRequests).toHaveBeenCalledWith( 119 | mockConnection, 120 | expect.any(String), 121 | 'test-repo', 122 | expect.objectContaining({ 123 | status: 'active', 124 | }), 125 | ); 126 | }); 127 | 128 | it('should handle get_pull_request_comments request', async () => { 129 | const mockComments = { 130 | threads: [ 131 | { 132 | id: 1, 133 | comments: [{ id: 1, content: 'Comment 1' }], 134 | }, 135 | ], 136 | }; 137 | (getPullRequestComments as jest.Mock).mockResolvedValue(mockComments); 138 | 139 | const request = { 140 | params: { 141 | name: 'get_pull_request_comments', 142 | arguments: { 143 | repositoryId: 'test-repo', 144 | pullRequestId: 123, 145 | }, 146 | }, 147 | method: 'tools/call', 148 | } as CallToolRequest; 149 | 150 | const response = await handlePullRequestsRequest(mockConnection, request); 151 | expect(response.content).toHaveLength(1); 152 | expect(JSON.parse(response.content[0].text as string)).toEqual( 153 | mockComments, 154 | ); 155 | expect(getPullRequestComments).toHaveBeenCalledWith( 156 | mockConnection, 157 | expect.any(String), 158 | 'test-repo', 159 | 123, 160 | expect.objectContaining({ 161 | pullRequestId: 123, 162 | }), 163 | ); 164 | }); 165 | 166 | it('should handle add_pull_request_comment request', async () => { 167 | const mockResult = { 168 | comment: { id: 1, content: 'New comment' }, 169 | thread: { id: 1 }, 170 | }; 171 | (addPullRequestComment as jest.Mock).mockResolvedValue(mockResult); 172 | 173 | const request = { 174 | params: { 175 | name: 'add_pull_request_comment', 176 | arguments: { 177 | repositoryId: 'test-repo', 178 | pullRequestId: 123, 179 | content: 'New comment', 180 | status: 'active', // Status is required when creating a new thread 181 | }, 182 | }, 183 | method: 'tools/call', 184 | } as CallToolRequest; 185 | 186 | // Mock the schema parsing 187 | const mockParsedArgs = { 188 | repositoryId: 'test-repo', 189 | pullRequestId: 123, 190 | content: 'New comment', 191 | status: 'active', 192 | }; 193 | 194 | // Use a different approach for mocking 195 | const originalParse = AddPullRequestCommentSchema.parse; 196 | AddPullRequestCommentSchema.parse = jest 197 | .fn() 198 | .mockReturnValue(mockParsedArgs); 199 | 200 | const response = await handlePullRequestsRequest(mockConnection, request); 201 | expect(response.content).toHaveLength(1); 202 | expect(JSON.parse(response.content[0].text as string)).toEqual( 203 | mockResult, 204 | ); 205 | expect(addPullRequestComment).toHaveBeenCalledWith( 206 | mockConnection, 207 | expect.any(String), 208 | 'test-repo', 209 | 123, 210 | expect.objectContaining({ 211 | content: 'New comment', 212 | }), 213 | ); 214 | 215 | // Restore the original parse function 216 | AddPullRequestCommentSchema.parse = originalParse; 217 | }); 218 | 219 | it('should throw error for unknown tool', async () => { 220 | const request = { 221 | params: { 222 | name: 'unknown_tool', 223 | arguments: {}, 224 | }, 225 | method: 'tools/call', 226 | } as CallToolRequest; 227 | 228 | await expect( 229 | handlePullRequestsRequest(mockConnection, request), 230 | ).rejects.toThrow('Unknown pull requests tool'); 231 | }); 232 | 233 | it('should propagate errors from pull request functions', async () => { 234 | const mockError = new Error('Test error'); 235 | (listPullRequests as jest.Mock).mockRejectedValue(mockError); 236 | 237 | const request = { 238 | params: { 239 | name: 'list_pull_requests', 240 | arguments: { 241 | repositoryId: 'test-repo', 242 | }, 243 | }, 244 | method: 'tools/call', 245 | } as CallToolRequest; 246 | 247 | await expect( 248 | handlePullRequestsRequest(mockConnection, request), 249 | ).rejects.toThrow(mockError); 250 | }); 251 | }); 252 | }); 253 | ``` -------------------------------------------------------------------------------- /src/features/pull-requests/add-pull-request-comment/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { WebApi } from 'azure-devops-node-api'; 2 | import { addPullRequestComment } from './feature'; 3 | import { 4 | Comment, 5 | CommentThreadStatus, 6 | CommentType, 7 | GitPullRequestCommentThread, 8 | } from 'azure-devops-node-api/interfaces/GitInterfaces'; 9 | 10 | describe('addPullRequestComment', () => { 11 | afterEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | test('should add a comment to an existing thread successfully', async () => { 16 | // Mock data for a new comment 17 | const mockComment: Comment = { 18 | id: 101, 19 | content: 'This is a reply comment', 20 | commentType: CommentType.Text, 21 | author: { 22 | displayName: 'Test User', 23 | id: 'test-user-id', 24 | }, 25 | publishedDate: new Date(), 26 | }; 27 | 28 | // Setup mock connection 29 | const mockGitApi = { 30 | createComment: jest.fn().mockResolvedValue(mockComment), 31 | createThread: jest.fn(), 32 | }; 33 | 34 | const mockConnection: any = { 35 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 36 | }; 37 | 38 | // Call the function with test parameters 39 | const projectId = 'test-project'; 40 | const repositoryId = 'test-repo'; 41 | const pullRequestId = 123; 42 | const threadId = 456; 43 | const options = { 44 | projectId, 45 | repositoryId, 46 | pullRequestId, 47 | threadId, 48 | content: 'This is a reply comment', 49 | }; 50 | 51 | const result = await addPullRequestComment( 52 | mockConnection as WebApi, 53 | projectId, 54 | repositoryId, 55 | pullRequestId, 56 | options, 57 | ); 58 | 59 | // Verify results (with transformed commentType) 60 | expect(result).toEqual({ 61 | comment: { 62 | ...mockComment, 63 | commentType: 'text', // Transform enum to string 64 | }, 65 | }); 66 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 67 | expect(mockGitApi.createComment).toHaveBeenCalledTimes(1); 68 | expect(mockGitApi.createComment).toHaveBeenCalledWith( 69 | expect.objectContaining({ content: 'This is a reply comment' }), 70 | repositoryId, 71 | pullRequestId, 72 | threadId, 73 | projectId, 74 | ); 75 | expect(mockGitApi.createThread).not.toHaveBeenCalled(); 76 | }); 77 | 78 | test('should create a new thread with a comment successfully', async () => { 79 | // Mock data for a new thread with comment 80 | const mockComment: Comment = { 81 | id: 100, 82 | content: 'This is a new comment', 83 | commentType: CommentType.Text, 84 | author: { 85 | displayName: 'Test User', 86 | id: 'test-user-id', 87 | }, 88 | publishedDate: new Date(), 89 | }; 90 | 91 | const mockThread: GitPullRequestCommentThread = { 92 | id: 789, 93 | comments: [mockComment], 94 | status: CommentThreadStatus.Active, 95 | }; 96 | 97 | // Setup mock connection 98 | const mockGitApi = { 99 | createComment: jest.fn(), 100 | createThread: jest.fn().mockResolvedValue(mockThread), 101 | }; 102 | 103 | const mockConnection: any = { 104 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 105 | }; 106 | 107 | // Call the function with test parameters 108 | const projectId = 'test-project'; 109 | const repositoryId = 'test-repo'; 110 | const pullRequestId = 123; 111 | const options = { 112 | projectId, 113 | repositoryId, 114 | pullRequestId, 115 | content: 'This is a new comment', 116 | status: 'active' as const, 117 | }; 118 | 119 | const result = await addPullRequestComment( 120 | mockConnection as WebApi, 121 | projectId, 122 | repositoryId, 123 | pullRequestId, 124 | options, 125 | ); 126 | 127 | // Verify results 128 | expect(result).toEqual({ 129 | comment: { 130 | ...mockComment, 131 | commentType: 'text', 132 | }, 133 | thread: { 134 | ...mockThread, 135 | status: 'active', 136 | comments: mockThread.comments?.map((comment) => ({ 137 | ...comment, 138 | commentType: 'text', 139 | })), 140 | }, 141 | }); 142 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 143 | expect(mockGitApi.createThread).toHaveBeenCalledTimes(1); 144 | expect(mockGitApi.createThread).toHaveBeenCalledWith( 145 | expect.objectContaining({ 146 | comments: [ 147 | expect.objectContaining({ content: 'This is a new comment' }), 148 | ], 149 | status: CommentThreadStatus.Active, 150 | }), 151 | repositoryId, 152 | pullRequestId, 153 | projectId, 154 | ); 155 | expect(mockGitApi.createComment).not.toHaveBeenCalled(); 156 | }); 157 | 158 | test('should create a new thread on a file with line number', async () => { 159 | // Mock data for a new thread with comment on file 160 | const mockComment: Comment = { 161 | id: 100, 162 | content: 'This code needs improvement', 163 | commentType: CommentType.Text, 164 | author: { 165 | displayName: 'Test User', 166 | id: 'test-user-id', 167 | }, 168 | publishedDate: new Date(), 169 | }; 170 | 171 | const mockThread: GitPullRequestCommentThread = { 172 | id: 789, 173 | status: CommentThreadStatus.Active, // Add missing status 174 | comments: [mockComment], 175 | threadContext: { 176 | filePath: '/src/app.ts', 177 | rightFileStart: { 178 | line: 42, 179 | offset: 1, 180 | }, 181 | rightFileEnd: { 182 | line: 42, 183 | offset: 1, 184 | }, 185 | }, 186 | }; 187 | 188 | // Setup mock connection 189 | const mockGitApi = { 190 | createComment: jest.fn(), 191 | createThread: jest.fn().mockResolvedValue(mockThread), 192 | }; 193 | 194 | const mockConnection: any = { 195 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 196 | }; 197 | 198 | // Call the function with test parameters 199 | const projectId = 'test-project'; 200 | const repositoryId = 'test-repo'; 201 | const pullRequestId = 123; 202 | const options = { 203 | projectId, 204 | repositoryId, 205 | pullRequestId, 206 | content: 'This code needs improvement', 207 | filePath: '/src/app.ts', 208 | lineNumber: 42, 209 | }; 210 | 211 | const result = await addPullRequestComment( 212 | mockConnection as WebApi, 213 | projectId, 214 | repositoryId, 215 | pullRequestId, 216 | options, 217 | ); 218 | 219 | // Verify results 220 | expect(result).toEqual({ 221 | comment: { 222 | ...mockComment, 223 | commentType: 'text', 224 | }, 225 | thread: { 226 | ...mockThread, 227 | status: 'active', 228 | comments: mockThread.comments?.map((comment) => ({ 229 | ...comment, 230 | commentType: 'text', 231 | })), 232 | }, 233 | }); 234 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); 235 | expect(mockGitApi.createThread).toHaveBeenCalledTimes(1); 236 | expect(mockGitApi.createThread).toHaveBeenCalledWith( 237 | expect.objectContaining({ 238 | comments: [ 239 | expect.objectContaining({ content: 'This code needs improvement' }), 240 | ], 241 | threadContext: expect.objectContaining({ 242 | filePath: '/src/app.ts', 243 | rightFileStart: expect.objectContaining({ line: 42 }), 244 | rightFileEnd: expect.objectContaining({ line: 42 }), 245 | }), 246 | }), 247 | repositoryId, 248 | pullRequestId, 249 | projectId, 250 | ); 251 | expect(mockGitApi.createComment).not.toHaveBeenCalled(); 252 | }); 253 | 254 | test('should handle error when API call fails', async () => { 255 | // Setup mock connection with error 256 | const errorMessage = 'API error'; 257 | const mockGitApi = { 258 | createComment: jest.fn().mockRejectedValue(new Error(errorMessage)), 259 | createThread: jest.fn(), 260 | }; 261 | 262 | const mockConnection: any = { 263 | getGitApi: jest.fn().mockResolvedValue(mockGitApi), 264 | }; 265 | 266 | // Call the function with test parameters 267 | const projectId = 'test-project'; 268 | const repositoryId = 'test-repo'; 269 | const pullRequestId = 123; 270 | const threadId = 456; 271 | const options = { 272 | projectId, 273 | repositoryId, 274 | pullRequestId, 275 | threadId, 276 | content: 'This is a reply comment', 277 | }; 278 | 279 | // Verify error handling 280 | await expect( 281 | addPullRequestComment( 282 | mockConnection as WebApi, 283 | projectId, 284 | repositoryId, 285 | pullRequestId, 286 | options, 287 | ), 288 | ).rejects.toThrow(`Failed to add pull request comment: ${errorMessage}`); 289 | }); 290 | }); 291 | ```