diff --git a/.eslintrc.json b/.eslintrc.json index 0155958..e799b23 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -15,10 +15,13 @@ "prettier/prettier": "error", "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ] }, "parserOptions": { "ecmaVersion": 2020, "sourceType": "module" } -} \ No newline at end of file +} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3ce63c1..0735b4c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -2,9 +2,9 @@ name: CI/CD on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: build: @@ -28,4 +28,4 @@ jobs: CI: 'true' AZURE_DEVOPS_ORG_URL: ${{ secrets.AZURE_DEVOPS_ORG_URL }} AZURE_DEVOPS_PAT: ${{ secrets.AZURE_DEVOPS_PAT }} - AZURE_DEVOPS_DEFAULT_PROJECT: ${{ secrets.AZURE_DEVOPS_DEFAULT_PROJECT }} \ No newline at end of file + AZURE_DEVOPS_DEFAULT_PROJECT: ${{ secrets.AZURE_DEVOPS_DEFAULT_PROJECT }} diff --git a/.prettierrc b/.prettierrc index b4de39e..6da7e31 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,4 +6,4 @@ "tabWidth": 2, "useTabs": false, "endOfLine": "lf" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 2e32883..27d5b64 100644 --- a/README.md +++ b/README.md @@ -43,12 +43,14 @@ The server is structured around the Model Context Protocol (MCP) for communicati ### Installation 1. Clone the repository: + ``` git clone https://github.com/your-username/azure-devops-mcp.git cd azure-devops-mcp ``` 2. Install dependencies: + ``` npm install ``` @@ -56,11 +58,14 @@ The server is structured around the Model Context Protocol (MCP) for communicati 3. Set up your environment: Option A: Using the automated setup script (recommended): + ``` chmod +x setup_env.sh ./setup_env.sh ``` + This script will: + - Check for and install the Azure CLI DevOps extension if needed - Let you select from your available Azure DevOps organizations - Optionally set a default project @@ -68,24 +73,29 @@ The server is structured around the Model Context Protocol (MCP) for communicati - Generate your `.env` file with the correct settings Option B: Manual setup: + ``` cp .env.example .env ``` + Then edit the `.env` file with your Azure DevOps credentials (see Authentication section below). ### Running the Server Build the TypeScript files: + ``` npm run build ``` Start the server: + ``` npm start ``` For development with hot reloading: + ``` npm run dev ``` @@ -108,26 +118,27 @@ For a complete list of environment variables and their descriptions, see the [Au Key environment variables include: -| Variable | Description | Required | Default | -|----------|-------------|----------|---------| -| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | -| `AZURE_DEVOPS_ORG` | Azure DevOps organization name | No | Extracted from URL | -| `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | -| `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | -| `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | -| `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | -| `AZURE_AD_TENANT_ID` | Azure AD tenant ID (for AAD auth) | Only with AAD auth | - | -| `AZURE_AD_CLIENT_ID` | Azure AD application ID (for AAD auth) | Only with AAD auth | - | -| `AZURE_AD_CLIENT_SECRET` | Azure AD client secret (for AAD auth) | Only with AAD auth | - | -| `PORT` | Server port | No | 3000 | -| `HOST` | Server host | No | localhost | -| `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info | +| Variable | Description | Required | Default | +| ------------------------------ | --------------------------------------------------------------- | ------------------ | ------------------ | +| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | +| `AZURE_DEVOPS_ORG` | Azure DevOps organization name | No | Extracted from URL | +| `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | +| `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | +| `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | +| `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | +| `AZURE_AD_TENANT_ID` | Azure AD tenant ID (for AAD auth) | Only with AAD auth | - | +| `AZURE_AD_CLIENT_ID` | Azure AD application ID (for AAD auth) | Only with AAD auth | - | +| `AZURE_AD_CLIENT_SECRET` | Azure AD client secret (for AAD auth) | Only with AAD auth | - | +| `PORT` | Server port | No | 3000 | +| `HOST` | Server host | No | localhost | +| `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info | ## Troubleshooting Authentication For detailed troubleshooting information for each authentication method, see the [Authentication Guide](docs/authentication.md#troubleshooting-authentication-issues). Common issues include: + - Invalid or expired credentials - Insufficient permissions - Network connectivity problems @@ -142,6 +153,7 @@ For technical details about how authentication is implemented in the Azure DevOp The Azure DevOps MCP server provides a variety of tools for interacting with Azure DevOps resources. For detailed documentation on each tool, please refer to the corresponding documentation. ### Core Navigation Tools + - `list_organizations`: List all accessible organizations - `list_projects`: List all accessible projects - `list_repositories`: List all repositories in a project @@ -149,16 +161,19 @@ The Azure DevOps MCP server provides a variety of tools for interacting with Azu For comprehensive documentation on all core navigation tools, see the [Core Navigation Tools Guide](docs/tools/core-navigation.md). ### Project Tools + - `get_project`: Get details of a specific project For project-specific tool documentation, see the [Projects Tools Guide](docs/tools/projects.md). ### Repository Tools + - `get_repository`: Get repository details For repository-specific tool documentation, see the [Repositories Tools Guide](docs/tools/repositories.md). ### Work Item Tools + - `get_work_item`: Retrieve a work item by ID - `create_work_item`: Create a new work item @@ -177,6 +192,7 @@ npm run test:unit Integration tests require a connection to a real Azure DevOps instance. To run them: 1. Ensure your `.env` file is configured with valid Azure DevOps credentials: + ``` AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_PAT=your-personal-access-token diff --git a/docs/authentication.md b/docs/authentication.md index 05c5150..bc1b2c8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -17,6 +17,7 @@ PAT authentication is the simplest method and works well for personal use or tes ### Setup Instructions 1. **Generate a PAT in Azure DevOps**: + - Go to https://dev.azure.com/{your-organization}/_usersSettings/tokens - Or click on your profile picture > Personal access tokens - Select "+ New Token" @@ -69,6 +70,7 @@ This makes it ideal for applications that need to work in different environments The SDK is already included as a dependency in the Azure DevOps MCP Server. 2. **Configure your `.env` file**: + ``` AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization @@ -78,18 +80,20 @@ This makes it ideal for applications that need to work in different environments 3. **Set up credentials based on your environment**: a. **For service principals (client credentials)**: - ``` - AZURE_TENANT_ID=your-tenant-id - AZURE_CLIENT_ID=your-client-id - AZURE_CLIENT_SECRET=your-client-secret - ``` + + ``` + AZURE_TENANT_ID=your-tenant-id + AZURE_CLIENT_ID=your-client-id + AZURE_CLIENT_SECRET=your-client-secret + ``` b. **For managed identities in Azure**: - No additional configuration needed if running in Azure with a managed identity. + No additional configuration needed if running in Azure with a managed identity. c. **For local development**: - - Log in with Azure CLI: `az login` - - Or use Visual Studio Code Azure Account extension + + - Log in with Azure CLI: `az login` + - Or use Visual Studio Code Azure Account extension ### Security Considerations @@ -105,9 +109,11 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id ### Setup Instructions 1. **Install the Azure CLI**: + - Follow the instructions at https://docs.microsoft.com/cli/azure/install-azure-cli 2. **Log in to Azure**: + ```bash az login ``` @@ -127,26 +133,28 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id ## Configuration Reference -| Environment Variable | Description | Required | Default | -|----------|-------------|----------|---------| -| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | -| `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | -| `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | -| `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | -| `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | -| `AZURE_TENANT_ID` | Azure AD tenant ID (for service principals) | Only with service principals | - | -| `AZURE_CLIENT_ID` | Azure AD application ID (for service principals) | Only with service principals | - | -| `AZURE_CLIENT_SECRET` | Azure AD client secret (for service principals) | Only with service principals | - | +| Environment Variable | Description | Required | Default | +| ------------------------------ | --------------------------------------------------------------- | ---------------------------- | ---------------- | +| `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) | No | `azure-identity` | +| `AZURE_DEVOPS_ORG_URL` | Full URL to your Azure DevOps organization | Yes | - | +| `AZURE_DEVOPS_PAT` | Personal Access Token (for PAT auth) | Only with PAT auth | - | +| `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified | No | - | +| `AZURE_DEVOPS_API_VERSION` | API version to use | No | Latest | +| `AZURE_TENANT_ID` | Azure AD tenant ID (for service principals) | Only with service principals | - | +| `AZURE_CLIENT_ID` | Azure AD application ID (for service principals) | Only with service principals | - | +| `AZURE_CLIENT_SECRET` | Azure AD client secret (for service principals) | Only with service principals | - | ## Troubleshooting Authentication Issues ### PAT Authentication Issues 1. **Invalid PAT**: Ensure your PAT hasn't expired and has the required scopes + - Error: `TF400813: The user 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' is not authorized to access this resource.` - Solution: Generate a new PAT with the correct scopes 2. **Scope issues**: If receiving 403 errors, check if your PAT has the necessary permissions + - Error: `TF401027: You need the Git 'Read' permission to perform this action.` - Solution: Update your PAT with the required scopes @@ -157,10 +165,12 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id ### Azure Identity Authentication Issues 1. **Missing credentials**: Ensure you have the necessary credentials configured + - Error: `CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token` - Solution: Check that you're logged in with Azure CLI or have environment variables set 2. **Permission issues**: Verify your identity has the necessary permissions + - Error: `AuthorizationFailed: The client does not have authorization to perform action` - Solution: Assign the appropriate roles to your identity @@ -171,10 +181,12 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id ### Azure CLI Authentication Issues 1. **CLI not installed**: Ensure Azure CLI is installed and in your PATH + - Error: `AzureCliCredential authentication failed: Azure CLI not found` - Solution: Install Azure CLI 2. **Not logged in**: Verify you're logged in to Azure CLI + - Error: `AzureCliCredential authentication failed: Please run 'az login'` - Solution: Run `az login` @@ -185,15 +197,18 @@ Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/id ## Best Practices 1. **Choose the right authentication method for your environment**: + - For local development: Azure CLI or PAT - For CI/CD pipelines: PAT or service principal - For Azure-hosted applications: Managed Identity 2. **Follow the principle of least privilege**: + - Only grant the permissions needed for your use case - Regularly review and rotate credentials 3. **Secure your credentials**: + - Use environment variables or a secrets manager - Never commit credentials to source control - Set appropriate expiration dates for PATs @@ -242,4 +257,4 @@ AZURE_CLIENT_SECRET=your-client-secret AZURE_DEVOPS_AUTH_METHOD=azure-cli AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_DEFAULT_PROJECT=MyProject -``` \ No newline at end of file +``` diff --git a/docs/azure-identity-authentication.md b/docs/azure-identity-authentication.md index 1539579..f68217b 100644 --- a/docs/azure-identity-authentication.md +++ b/docs/azure-identity-authentication.md @@ -57,7 +57,7 @@ Implement a factory pattern to create the appropriate authentication provider ba // src/api/auth.ts export enum AuthMethod { PAT = 'pat', - AZURE_IDENTITY = 'azure-identity' + AZURE_IDENTITY = 'azure-identity', } export function createAuthProvider(config: AzureDevOpsConfig): AuthProvider { @@ -80,59 +80,63 @@ Implement the Azure Identity authentication provider: export class AzureIdentityAuthProvider implements AuthProvider { private config: AzureDevOpsConfig; private connectionPromise: Promise | null = null; - + constructor(config: AzureDevOpsConfig) { this.config = config; } - + async getConnection(): Promise { if (!this.connectionPromise) { this.connectionPromise = this.createConnection(); } return this.connectionPromise; } - + private async createConnection(): Promise { try { // Azure DevOps resource ID for token scope const azureDevOpsResourceId = '499b84ac-1321-427f-aa17-267ca6975798'; - + // Create credential based on configuration const credential = this.createCredential(); - + // Get token for Azure DevOps - const token = await credential.getToken(`${azureDevOpsResourceId}/.default`); - + const token = await credential.getToken( + `${azureDevOpsResourceId}/.default`, + ); + if (!token) { - throw new AzureDevOpsAuthenticationError('Failed to acquire token from Azure Identity'); + throw new AzureDevOpsAuthenticationError( + 'Failed to acquire token from Azure Identity', + ); } - + // Create auth handler with token const authHandler = new BearerCredentialHandler(token.token); - + // Create WebApi client const connection = new WebApi(this.config.organizationUrl, authHandler); - + // Test the connection await connection.getLocationsApi(); - + return connection; } catch (error) { throw new AzureDevOpsAuthenticationError( - `Failed to authenticate with Azure Identity: ${error instanceof Error ? error.message : String(error)}` + `Failed to authenticate with Azure Identity: ${error instanceof Error ? error.message : String(error)}`, ); } } - + private createCredential(): TokenCredential { if (this.config.azureIdentityOptions?.useAzureCliCredential) { return new AzureCliCredential(); } - + // Default to DefaultAzureCredential return new DefaultAzureCredential(); } - + async isAuthenticated(): Promise { try { await this.getConnection(); @@ -156,7 +160,7 @@ export interface AzureDevOpsConfig { personalAccessToken?: string; defaultProject?: string; apiVersion?: string; - + // New properties authMethod?: AuthMethod; azureIdentityOptions?: { @@ -177,10 +181,12 @@ const config: AzureDevOpsConfig = { personalAccessToken: process.env.AZURE_DEVOPS_PAT, defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, apiVersion: process.env.AZURE_DEVOPS_API_VERSION, - authMethod: (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthMethod) || AuthMethod.PAT, + authMethod: + (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthMethod) || AuthMethod.PAT, azureIdentityOptions: { - useAzureCliCredential: process.env.AZURE_DEVOPS_USE_CLI_CREDENTIAL === 'true' - } + useAzureCliCredential: + process.env.AZURE_DEVOPS_USE_CLI_CREDENTIAL === 'true', + }, }; ``` @@ -192,15 +198,15 @@ Update the `AzureDevOpsClient` class to use the authentication provider: // src/api/client.ts export class AzureDevOpsClient { private authProvider: AuthProvider; - + constructor(config: AzureDevOpsConfig) { this.authProvider = createAuthProvider(config); } - + private async getClient(): Promise { return this.authProvider.getConnection(); } - + // Rest of the class remains the same } ``` @@ -261,4 +267,4 @@ Update the README.md and other documentation to include information about the ne ## Conclusion -This implementation approach provides a flexible and extensible way to add Azure Identity authentication support to the Azure DevOps MCP Server. It allows users to choose the authentication method that best suits their environment and needs, while maintaining backward compatibility with the existing PAT authentication method. \ No newline at end of file +This implementation approach provides a flexible and extensible way to add Azure Identity authentication support to the Azure DevOps MCP Server. It allows users to choose the authentication method that best suits their environment and needs, while maintaining backward compatibility with the existing PAT authentication method. diff --git a/docs/ci-setup.md b/docs/ci-setup.md index 8221414..65b5c56 100644 --- a/docs/ci-setup.md +++ b/docs/ci-setup.md @@ -18,14 +18,17 @@ To run integration tests in the CI environment, you need to configure the follow 4. Add each of the required secrets: #### AZURE_DEVOPS_ORG_URL + - Name: `AZURE_DEVOPS_ORG_URL` - Value: `https://dev.azure.com/your-organization` #### AZURE_DEVOPS_PAT + - Name: `AZURE_DEVOPS_PAT` - Value: Your Personal Access Token #### AZURE_DEVOPS_DEFAULT_PROJECT (optional) + - Name: `AZURE_DEVOPS_DEFAULT_PROJECT` - Value: Your project name @@ -55,4 +58,4 @@ If integration tests fail in CI: 2. Verify that the PAT has not expired 3. Ensure the PAT has the required permissions 4. Confirm that the organization URL is correct -5. Check if the default project exists and is accessible with the provided PAT \ No newline at end of file +5. Check if the default project exists and is accessible with the provided PAT diff --git a/docs/examples/README.md b/docs/examples/README.md index 5324b2e..058a2a8 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -30,4 +30,4 @@ npm start ## Additional Resources -For more detailed information about authentication methods, setup instructions, and troubleshooting, refer to the [Authentication Guide](../authentication.md). \ No newline at end of file +For more detailed information about authentication methods, setup instructions, and troubleshooting, refer to the [Authentication Guide](../authentication.md). diff --git a/docs/tools/README.md b/docs/tools/README.md index 772501a..31ffe25 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -13,17 +13,21 @@ This directory contains documentation for all tools available in the Azure DevOp ## Tools by Category ### Organization Tools + - [`list_organizations`](./organizations.md#list_organizations) - List all Azure DevOps organizations accessible to the user ### Project Tools + - [`list_projects`](./projects.md#list_projects) - List all projects in the organization - [`get_project`](./projects.md#get_project) - Get details of a specific project ### Repository Tools + - [`list_repositories`](./repositories.md#list_repositories) - List all repositories in a project - [`get_repository`](./repositories.md#get_repository) - Get details of a specific repository ### Work Item Tools + - [`get_work_item`](./work-items.md#get_work_item) - Retrieve a work item by ID - [`create_work_item`](./work-items.md#create_work_item) - Create a new work item - [`list_work_items`](./work-items.md#list_work_items) - List work items in a project @@ -41,4 +45,4 @@ Each tool documentation follows a consistent structure: ## Examples -Examples of using multiple tools together can be found in the [Core Navigation Tools](./core-navigation.md#common-use-cases) documentation. \ No newline at end of file +Examples of using multiple tools together can be found in the [Core Navigation Tools](./core-navigation.md#common-use-cases) documentation. diff --git a/docs/tools/core-navigation.md b/docs/tools/core-navigation.md index 7eef81f..c68c4c2 100644 --- a/docs/tools/core-navigation.md +++ b/docs/tools/core-navigation.md @@ -17,11 +17,11 @@ The core navigation tools allow you to explore this hierarchy from top to bottom ## Available Tools -| Tool Name | Description | Required Parameters | Optional Parameters | -|-----------|-------------|---------------------|---------------------| -| [`list_organizations`](./organizations.md#list_organizations) | Lists all Azure DevOps organizations accessible to the user | None | None | -| [`list_projects`](./projects.md#list_projects) | Lists all projects in the organization | None | stateFilter, top, skip, continuationToken | -| [`list_repositories`](./repositories.md#list_repositories) | Lists all repositories in a project | projectId | includeLinks | +| Tool Name | Description | Required Parameters | Optional Parameters | +| ------------------------------------------------------------- | ----------------------------------------------------------- | ------------------- | ----------------------------------------- | +| [`list_organizations`](./organizations.md#list_organizations) | Lists all Azure DevOps organizations accessible to the user | None | None | +| [`list_projects`](./projects.md#list_projects) | Lists all projects in the organization | None | stateFilter, top, skip, continuationToken | +| [`list_repositories`](./repositories.md#list_repositories) | Lists all repositories in a project | projectId | includeLinks | ## Common Use Cases @@ -37,16 +37,16 @@ Example: ```typescript // Step 1: Get all organizations -const organizations = await mcpClient.callTool("list_organizations", {}); +const organizations = await mcpClient.callTool('list_organizations', {}); const myOrg = organizations[0]; // Use the first organization for this example // Step 2: Get all projects in the organization -const projects = await mcpClient.callTool("list_projects", {}); +const projects = await mcpClient.callTool('list_projects', {}); const myProject = projects[0]; // Use the first project for this example // Step 3: Get all repositories in the project -const repositories = await mcpClient.callTool("list_repositories", { - projectId: myProject.name +const repositories = await mcpClient.callTool('list_repositories', { + projectId: myProject.name, }); ``` @@ -56,8 +56,8 @@ You can filter projects based on their state: ```typescript // Get only well-formed projects (state = 1) -const wellFormedProjects = await mcpClient.callTool("list_projects", { - stateFilter: 1 +const wellFormedProjects = await mcpClient.callTool('list_projects', { + stateFilter: 1, }); ``` @@ -67,15 +67,15 @@ For organizations with many projects or repositories, you can use pagination: ```typescript // Get projects with pagination (first 10 projects) -const firstPage = await mcpClient.callTool("list_projects", { +const firstPage = await mcpClient.callTool('list_projects', { top: 10, - skip: 0 + skip: 0, }); // Get the next 10 projects -const secondPage = await mcpClient.callTool("list_projects", { +const secondPage = await mcpClient.callTool('list_projects', { top: 10, - skip: 10 + skip: 10, }); ``` @@ -89,4 +89,4 @@ For detailed information about each tool, including parameters, response format, ## Error Handling -Each of these tools may throw various errors, such as authentication errors or permission errors. Be sure to implement proper error handling when using these tools. Refer to the individual tool documentation for specific error types that each tool might throw. \ No newline at end of file +Each of these tools may throw various errors, such as authentication errors or permission errors. Be sure to implement proper error handling when using these tools. Refer to the individual tool documentation for specific error types that each tool might throw. diff --git a/docs/tools/organizations.md b/docs/tools/organizations.md index 6928c7e..90a2bce 100644 --- a/docs/tools/organizations.md +++ b/docs/tools/organizations.md @@ -58,7 +58,7 @@ The tool may throw the following errors: ```typescript // Example MCP client call -const result = await mcpClient.callTool("list_organizations", {}); +const result = await mcpClient.callTool('list_organizations', {}); console.log(result); ``` @@ -70,4 +70,4 @@ This tool uses a two-step process to retrieve organizations: 2. Then it extracts the `publicAlias` from the profile response 3. Finally, it uses the `publicAlias` to get organizations from `https://app.vssps.visualstudio.com/_apis/accounts?memberId={publicAlias}` -Authentication is handled using Basic Auth with the Personal Access Token. \ No newline at end of file +Authentication is handled using Basic Auth with the Personal Access Token. diff --git a/docs/tools/projects.md b/docs/tools/projects.md index 5f78489..c44577a 100644 --- a/docs/tools/projects.md +++ b/docs/tools/projects.md @@ -18,19 +18,19 @@ All parameters are optional: ```json { - "stateFilter": 1, // Optional: Filter on team project state - "top": 100, // Optional: Maximum number of projects to return - "skip": 0, // Optional: Number of projects to skip - "continuationToken": 123 // Optional: Gets projects after the continuation token provided + "stateFilter": 1, // Optional: Filter on team project state + "top": 100, // Optional: Maximum number of projects to return + "skip": 0, // Optional: Number of projects to skip + "continuationToken": 123 // Optional: Gets projects after the continuation token provided } ``` -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `stateFilter` | number | No | Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new) | -| `top` | number | No | Maximum number of projects to return in a single request | -| `skip` | number | No | Number of projects to skip, useful for pagination | -| `continuationToken` | number | No | Gets the projects after the continuation token provided | +| Parameter | Type | Required | Description | +| ------------------- | ------ | -------- | --------------------------------------------------------------------------------------- | +| `stateFilter` | number | No | Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new) | +| `top` | number | No | Maximum number of projects to return in a single request | +| `skip` | number | No | Number of projects to skip, useful for pagination | +| `continuationToken` | number | No | Gets the projects after the continuation token provided | ### Response @@ -87,19 +87,19 @@ Error messages will be formatted as text and provide details about what went wro ```typescript // Example with no parameters (returns all projects) -const allProjects = await mcpClient.callTool("list_projects", {}); +const allProjects = await mcpClient.callTool('list_projects', {}); console.log(allProjects); // Example with pagination parameters -const paginatedProjects = await mcpClient.callTool("list_projects", { +const paginatedProjects = await mcpClient.callTool('list_projects', { top: 10, - skip: 20 + skip: 20, }); console.log(paginatedProjects); // Example with state filter (only well-formed projects) -const wellFormedProjects = await mcpClient.callTool("list_projects", { - stateFilter: 1 +const wellFormedProjects = await mcpClient.callTool('list_projects', { + stateFilter: 1, }); console.log(wellFormedProjects); ``` @@ -111,4 +111,4 @@ This tool uses the Azure DevOps Node API's Core API to retrieve projects: 1. It gets a connection to the Azure DevOps WebApi client 2. It calls the `getCoreApi()` method to get a handle to the Core API 3. It then calls `getProjects()` with any provided parameters to retrieve the list of projects -4. The results are returned directly to the caller \ No newline at end of file +4. The results are returned directly to the caller diff --git a/docs/tools/repositories.md b/docs/tools/repositories.md index 867506e..57c5239 100644 --- a/docs/tools/repositories.md +++ b/docs/tools/repositories.md @@ -16,15 +16,15 @@ This tool uses the Azure DevOps WebApi client to interact with the Git API. ```json { - "projectId": "MyProject", // Required: The ID or name of the project - "includeLinks": true // Optional: Whether to include reference links + "projectId": "MyProject", // Required: The ID or name of the project + "includeLinks": true // Optional: Whether to include reference links } ``` -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectId` | string | Yes | The ID or name of the project containing the repositories | -| `includeLinks` | boolean | No | Whether to include reference links in the repository objects | +| Parameter | Type | Required | Description | +| -------------- | ------- | -------- | ------------------------------------------------------------ | +| `projectId` | string | Yes | The ID or name of the project containing the repositories | +| `includeLinks` | boolean | No | Whether to include reference links in the repository objects | ### Response @@ -93,15 +93,15 @@ Error messages will be formatted as text and provide details about what went wro ```typescript // Basic example -const repositories = await mcpClient.callTool("list_repositories", { - projectId: "MyProject" +const repositories = await mcpClient.callTool('list_repositories', { + projectId: 'MyProject', }); console.log(repositories); // Example with includeLinks parameter -const repositoriesWithLinks = await mcpClient.callTool("list_repositories", { - projectId: "MyProject", - includeLinks: true +const repositoriesWithLinks = await mcpClient.callTool('list_repositories', { + projectId: 'MyProject', + includeLinks: true, }); console.log(repositoriesWithLinks); ``` @@ -118,4 +118,4 @@ This tool uses the Azure DevOps Node API's Git API to retrieve repositories: ### Related Tools - `get_repository`: Get details of a specific repository -- `list_projects`: List all projects in the organization (to find project IDs) \ No newline at end of file +- `list_projects`: List all projects in the organization (to find project IDs) diff --git a/docs/tools/work-items.md b/docs/tools/work-items.md index fbc3cb2..734eb3f 100644 --- a/docs/tools/work-items.md +++ b/docs/tools/work-items.md @@ -14,10 +14,10 @@ Retrieves a work item by its ID. ### Parameters -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `workItemId` | number | Yes | The ID of the work item to retrieve | -| `expand` | string | No | Controls the level of detail in the response (e.g., "All", "Relations", "Fields") | +| Parameter | Type | Required | Description | +| ------------ | ------ | -------- | --------------------------------------------------------------------------------- | +| `workItemId` | number | Yes | The ID of the work item to retrieve | +| `expand` | string | No | Controls the level of detail in the response (e.g., "All", "Relations", "Fields") | ### Response @@ -46,7 +46,7 @@ Returns a work item object with the following structure: ```javascript const result = await callTool('get_work_item', { - workItemId: 123 + workItemId: 123, }); ``` @@ -56,17 +56,17 @@ Creates a new work item in a specified project. ### Parameters -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectId` | string | Yes | The ID or name of the project where the work item will be created | -| `workItemType` | string | Yes | The type of work item to create (e.g., "Task", "Bug", "User Story") | -| `title` | string | Yes | The title of the work item | -| `description` | string | No | The description of the work item | -| `assignedTo` | string | No | The email or name of the user to assign the work item to | -| `areaPath` | string | No | The area path for the work item | -| `iterationPath` | string | No | The iteration path for the work item | -| `priority` | number | No | The priority of the work item | -| `additionalFields` | object | No | Additional fields to set on the work item (key-value pairs) | +| Parameter | Type | Required | Description | +| ------------------ | ------ | -------- | ------------------------------------------------------------------- | +| `projectId` | string | Yes | The ID or name of the project where the work item will be created | +| `workItemType` | string | Yes | The type of work item to create (e.g., "Task", "Bug", "User Story") | +| `title` | string | Yes | The title of the work item | +| `description` | string | No | The description of the work item | +| `assignedTo` | string | No | The email or name of the user to assign the work item to | +| `areaPath` | string | No | The area path for the work item | +| `iterationPath` | string | No | The iteration path for the work item | +| `priority` | number | No | The priority of the work item | +| `additionalFields` | object | No | Additional fields to set on the work item (key-value pairs) | ### Response @@ -102,12 +102,13 @@ const result = await callTool('create_work_item', { projectId: 'my-project', workItemType: 'User Story', title: 'Implement login functionality', - description: 'Create a secure login system with email and password authentication', + description: + 'Create a secure login system with email and password authentication', assignedTo: 'developer@example.com', priority: 1, additionalFields: { - 'Custom.Field': 'Custom Value' - } + 'Custom.Field': 'Custom Value', + }, }); ``` @@ -121,14 +122,14 @@ Lists work items in a specified project. ### Parameters -| Parameter | Type | Required | Description | -|-----------|------|----------|-------------| -| `projectId` | string | Yes | The ID or name of the project to list work items from | -| `teamId` | string | No | The ID of the team to list work items for | -| `queryId` | string | No | ID of a saved work item query | -| `wiql` | string | No | Work Item Query Language (WIQL) query | -| `top` | number | No | Maximum number of work items to return | -| `skip` | number | No | Number of work items to skip | +| Parameter | Type | Required | Description | +| ----------- | ------ | -------- | ----------------------------------------------------- | +| `projectId` | string | Yes | The ID or name of the project to list work items from | +| `teamId` | string | No | The ID of the team to list work items for | +| `queryId` | string | No | ID of a saved work item query | +| `wiql` | string | No | Work Item Query Language (WIQL) query | +| `top` | number | No | Maximum number of work items to return | +| `skip` | number | No | Number of work items to skip | ### Response @@ -169,6 +170,6 @@ Returns an array of work item objects: const result = await callTool('list_work_items', { projectId: 'my-project', wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Task' ORDER BY [System.CreatedDate] DESC", - top: 10 + top: 10, }); -``` \ No newline at end of file +``` diff --git a/finish_task.sh b/finish_task.sh index 46e3310..12c1c44 100755 --- a/finish_task.sh +++ b/finish_task.sh @@ -17,32 +17,31 @@ if [ "$CURRENT_BRANCH" = "main" ]; then exit 1 fi -# Stage all changes -echo "Staging all changes..." -git add . +# Check if there are any uncommitted changes +if ! git diff --quiet || ! git diff --staged --quiet; then + # Stage all changes + echo "Staging all changes..." + git add . -# Check if there are any changes to commit -if git diff --staged --quiet; then - echo "No changes to commit. Have you made any changes?" - exit 1 -fi - -# Commit changes -echo "Committing changes with title: $PR_TITLE" -git commit -m "$PR_TITLE" -m "$PR_DESCRIPTION" + # Commit changes + echo "Committing changes with title: $PR_TITLE" + git commit -m "$PR_TITLE" -m "$PR_DESCRIPTION" -if [ $? -ne 0 ]; then - echo "Failed to commit changes." - exit 1 -fi + if [ $? -ne 0 ]; then + echo "Failed to commit changes." + exit 1 + fi -# Push changes to remote -echo "Pushing changes to origin/$CURRENT_BRANCH..." -git push -u origin "$CURRENT_BRANCH" + # Push changes to remote + echo "Pushing changes to origin/$CURRENT_BRANCH..." + git push -u origin "$CURRENT_BRANCH" -if [ $? -ne 0 ]; then - echo "Failed to push changes to remote." - exit 1 + if [ $? -ne 0 ]; then + echo "Failed to push changes to remote." + exit 1 + fi +else + echo "No uncommitted changes found. Proceeding with PR creation for already committed changes." fi # Create PR using GitHub CLI diff --git a/jest.config.js b/jest.config.js index b4a65de..5770e8b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,40 +2,29 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/tests'], + roots: ['/tests', '/src'], testMatch: ['**/*.test.ts'], moduleNameMapper: { - '^@/(.*)$': '/src/$1' + '^@/(.*)$': '/src/$1', }, collectCoverage: true, collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', '!src/**/index.ts', - '!src/**/*.test.ts', - '!src/types/**/*' + '!src/types/**/*', ], coverageDirectory: 'coverage', - coverageReporters: [ - 'text', - 'lcov', - 'html', - 'cobertura' - ], + coverageReporters: ['text', 'lcov', 'html', 'cobertura'], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, - statements: 80 - } + statements: 80, + }, }, verbose: true, - testPathIgnorePatterns: [ - '/node_modules/', - '/dist/' - ], - setupFilesAfterEnv: [ - '/tests/setup.ts' - ] -}; \ No newline at end of file + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + setupFilesAfterEnv: ['/tests/setup.ts'], +}; diff --git a/jest.integration.config.js b/jest.integration.config.js index 868ab64..d7b0eab 100644 --- a/jest.integration.config.js +++ b/jest.integration.config.js @@ -8,8 +8,11 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts'], coverageDirectory: 'coverage/integration', transform: { - '^.+\\.ts$': ['ts-jest', { - tsconfig: 'tsconfig.json', - }] - } -}; \ No newline at end of file + '^.+\\.ts$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, +}; diff --git a/project-management/planning/architecture-guide.md b/project-management/planning/architecture-guide.md index 211d40e..e8e48fd 100644 --- a/project-management/planning/architecture-guide.md +++ b/project-management/planning/architecture-guide.md @@ -1,10 +1,13 @@ ## Architectural Guide ### Overview + The architectural guide outlines a modular, tool-based structure for the Azure DevOps MCP server, aligning with MCP’s design principles. It emphasizes clarity, maintainability, and scalability, while incorporating best practices for authentication, error handling, and security. This structure ensures the server is extensible and adaptable to evolving requirements. ### Server Structure + The server is organized into distinct modules, each with a specific responsibility: + - **Tools Module**: Houses the definitions and implementations of MCP tools (e.g., `list_projects`, `create_work_item`). Each tool is an async function with defined inputs and outputs. - **API Client Module**: Abstracts interactions with Azure DevOps APIs, supporting both PAT and AAD authentication. It provides a unified interface for tools to access API functionality. - **Configuration Module**: Manages server settings, such as authentication methods and default Azure DevOps organization/project/repository values, loaded from environment variables or a config file. @@ -12,6 +15,7 @@ The server is organized into distinct modules, each with a specific responsibili - **Server Entry Point**: The main file (e.g., `index.ts`) that initializes the server with `getMcpServer`, registers tools, and starts the server. ### Authentication and Configuration + - **Multiple Authentication Methods**: Supports PAT and AAD token-based authentication, configurable via an environment variable (e.g., `AZURE_DEVOPS_AUTH_METHOD`). - **PAT**: Uses the `WebApi` class from `azure-devops-node-api`. - **AAD**: Implements a custom Axios-based client with Bearer token authorization. @@ -19,6 +23,7 @@ The server is organized into distinct modules, each with a specific responsibili - **Default Settings**: Allows configuration of default organization, project, and repository values, with tools able to override these via parameters. ### Tool Implementation + - **Tool Definitions**: Each tool specifies a name, an async handler, and an inputs schema. Example: ```ts const listProjects = { @@ -33,22 +38,25 @@ The server is organized into distinct modules, each with a specific responsibili - **Safe Operations**: Ensures tools perform non-destructive actions (e.g., creating commits instead of force pushing) and validate inputs to prevent errors or security issues. ### API Client Management + - **Singleton API Client**: Reuses a single API client instance (e.g., `WebApi` or Axios-based) across tools to optimize performance and reduce overhead. - **Conditional Initialization**: Initializes the client based on the selected authentication method, maintaining flexibility without code duplication. ### Security Best Practices + - **Minimal Permissions**: Recommends scoping PATs and AAD service principals to the least required privileges (e.g., read-only for listing operations). - **Logging and Auditing**: Implements logging for tool executions and errors, avoiding exposure of sensitive data. - **Rate Limiting**: Handles API rate limits (e.g., 429 errors) with retry logic to maintain responsiveness. - **Secure Communication**: Assumes MCP’s local socket communication is secure; ensures any remote connections use HTTPS. ### Testing and Quality Assurance + - **Unit Tests**: Verifies individual tool functionality and error handling. - **Integration Tests**: Validates end-to-end workflows (e.g., user story to pull request). - **Security Testing**: Checks for vulnerabilities like injection attacks or unauthorized access. ### Documentation + - **README.md**: Provides setup instructions, authentication setup, tool descriptions, and usage examples. - **Examples Folder**: Includes sample configurations and tool usage scenarios (e.g., integration with MCP clients like Claude Desktop). - **Troubleshooting Guide**: Addresses common issues, such as authentication errors or API rate limits. - diff --git a/project-management/planning/detailed-outline-of-features.md b/project-management/planning/detailed-outline-of-features.md index ee715ef..a9f8643 100644 --- a/project-management/planning/detailed-outline-of-features.md +++ b/project-management/planning/detailed-outline-of-features.md @@ -5,86 +5,98 @@ You’re absolutely right—adding a section for **Core Functionality** is a gre ### Updated Outline for Azure DevOps MCP Server #### Objective + Develop an Azure DevOps reference server for MCP that allows an AI (e.g., via Claude Desktop) to fully interact with Azure DevOps, supporting a broad feature set including core navigation, repository management, work items, pipelines, and more. The server will enable an end-to-end workflow from user story to pull request while providing foundational tools to explore the Azure DevOps ecosystem. #### Key Requirements Recap -- **Broad Functionality**: Mirror GitHub’s 22 tools and extend with Azure DevOps-specific features. -- **Infinite Use Cases**: Flexible for limitless AI scenarios. -- **MCP Integration**: Compatible with MCP clients like Claude Desktop. -- **Authentication**: Support PAT, OAuth, and Azure Identity. -- **Security**: Granular permissions and safe operations. -- **Tech Stack**: Typescript with MCP SDK, running locally. -- **Key Use Case**: AI autonomously handles user story to pull request. + +- **Broad Functionality**: Mirror GitHub’s 22 tools and extend with Azure DevOps-specific features. +- **Infinite Use Cases**: Flexible for limitless AI scenarios. +- **MCP Integration**: Compatible with MCP clients like Claude Desktop. +- **Authentication**: Support PAT, OAuth, and Azure Identity. +- **Security**: Granular permissions and safe operations. +- **Tech Stack**: Typescript with MCP SDK, running locally. +- **Key Use Case**: AI autonomously handles user story to pull request. - **New Addition**: Core functionality for listing orgs, projects, repos, etc. #### Tools (Logical Sections with Core Functionality Added) + Here’s the updated toolset, now including a **Core Functionality** section, with approximately 30–35 tools total to reflect Azure DevOps’ complexity. -1. **Core Functionality** *(New Section)* - - `list_organizations`: List all organizations accessible to the authenticated user. - - `list_projects`: List projects within a specified organization. - - `list_repositories`: List repositories within a project. - - `get_project_details`: Fetch details of a specific project (e.g., description, capabilities). - - `get_repository_details`: Fetch details of a specific repository (e.g., default branch, size). +1. **Core Functionality** _(New Section)_ + + - `list_organizations`: List all organizations accessible to the authenticated user. + - `list_projects`: List projects within a specified organization. + - `list_repositories`: List repositories within a project. + - `get_project_details`: Fetch details of a specific project (e.g., description, capabilities). + - `get_repository_details`: Fetch details of a specific repository (e.g., default branch, size). - **Use Case**: AI identifies the right org/project/repo to work in before starting a task (e.g., finding “DevTeam” org, “AppDev” project, “MainApp” repo for a user story). -2. **Repository Operations** - - `create_or_update_file`: Add or edit files. - - `push_changes`: Commit and push with history preservation. - - `get_file_contents`: Retrieve file contents. +2. **Repository Operations** + + - `create_or_update_file`: Add or edit files. + - `push_changes`: Commit and push with history preservation. + - `get_file_contents`: Retrieve file contents. - **Use Case**: AI commits code to the “MainApp” repo for a feature. -3. **Branch and Pull Request Management** - - `create_branch`: Create a new branch. - - `create_pull_request`: Submit a PR. - - `merge_pull_request`: Merge an approved PR. - - `get_pull_request`: Fetch PR details. - - `list_pull_requests`: List all PRs. +3. **Branch and Pull Request Management** + + - `create_branch`: Create a new branch. + - `create_pull_request`: Submit a PR. + - `merge_pull_request`: Merge an approved PR. + - `get_pull_request`: Fetch PR details. + - `list_pull_requests`: List all PRs. - **Use Case**: AI creates a branch and PR for the user story’s code. -4. **Work Item Management** - - `create_work_item`: Create user stories, tasks, or bugs. - - `update_work_item`: Edit work item details. - - `list_work_items`: Fetch work items. - - `get_work_item`: Retrieve a specific work item. - - `add_work_item_comment`: Add comments. +4. **Work Item Management** + + - `create_work_item`: Create user stories, tasks, or bugs. + - `update_work_item`: Edit work item details. + - `list_work_items`: Fetch work items. + - `get_work_item`: Retrieve a specific work item. + - `add_work_item_comment`: Add comments. - **Use Case**: AI creates and updates a user story linked to the PR. -5. **Pipeline Interactions** - - `trigger_pipeline`: Start a build/release pipeline. - - `get_pipeline_status`: Check pipeline status. - - `list_pipelines`: List available pipelines. +5. **Pipeline Interactions** + + - `trigger_pipeline`: Start a build/release pipeline. + - `get_pipeline_status`: Check pipeline status. + - `list_pipelines`: List available pipelines. - **Use Case**: AI triggers a pipeline to validate code before PR submission. -6. **Search and Query** - - `search_repos`: Find repositories by criteria. - - `search_work_items`: Query work items. - - `search_commits`: Search commit history. +6. **Search and Query** + + - `search_repos`: Find repositories by criteria. + - `search_work_items`: Query work items. + - `search_commits`: Search commit history. - **Use Case**: AI checks for duplicate work items or relevant commits. -7. **Azure DevOps-Specific Features** - - `update_board`: Move work items on Kanban/Sprint boards. - - `create_test_plan`: Set up a test plan. - - `publish_artifact`: Upload build artifacts. +7. **Azure DevOps-Specific Features** + - `update_board`: Move work items on Kanban/Sprint boards. + - `create_test_plan`: Set up a test plan. + - `publish_artifact`: Upload build artifacts. - **Use Case**: AI updates the board and publishes test results. #### Authentication and Security -- **Methods**: - - **PAT**: Token-based access (e.g., `AZURE_DEVOPS_PAT`). - - **OAuth**: Delegated access via Azure AD. - - **Azure Identity**: Seamless AD integration (e.g., `AZURE_AD_TOKEN`). -- **Security**: - - Scope-limiting (e.g., restrict to one project/repo). - - History preservation (no force pushes). + +- **Methods**: + - **PAT**: Token-based access (e.g., `AZURE_DEVOPS_PAT`). + - **OAuth**: Delegated access via Azure AD. + - **Azure Identity**: Seamless AD integration (e.g., `AZURE_AD_TOKEN`). +- **Security**: + - Scope-limiting (e.g., restrict to one project/repo). + - History preservation (no force pushes). - Logging for auditability. #### Technical Approach -- **Language**: Typescript with MCP Typescript SDK. -- **API**: `azure-devops-node-api` for Azure DevOps REST API calls ([docs.microsoft.com/rest/api/azure/devops](https://learn.microsoft.com/en-us/rest/api/azure/devops/)). -- **Deployment**: Local via `npx @your-org/server-azuredevops`. + +- **Language**: Typescript with MCP Typescript SDK. +- **API**: `azure-devops-node-api` for Azure DevOps REST API calls ([docs.microsoft.com/rest/api/azure/devops](https://learn.microsoft.com/en-us/rest/api/azure/devops/)). +- **Deployment**: Local via `npx @your-org/server-azuredevops`. - **MCP**: Advertise tools as capabilities (e.g., `list_organizations`, `create_pull_request`). #### Updated Configuration Example + ```json { "mcpServers": { @@ -103,12 +115,13 @@ Here’s the updated toolset, now including a **Core Functionality** section, wi ``` #### End-to-End Use Case: User Story to Pull Request -1. **Discover Context**: AI lists organizations (`list_organizations`), projects (`list_projects`), and repos (`list_repositories`) to target “your-org/AppDev/MainApp.” -2. **Create User Story**: AI creates a user story (`create_work_item`). -3. **Branch and Code**: AI creates a branch (`create_branch`), writes code (`create_or_update_file`), and pushes (`push_changes`). -4. **Pipeline**: AI triggers a build (`trigger_pipeline`) and checks status (`get_pipeline_status`). -5. **Pull Request**: AI submits a PR (`create_pull_request`) linked to the user story. -6. **Board Update**: AI moves the user story to “In Review” (`update_board`). + +1. **Discover Context**: AI lists organizations (`list_organizations`), projects (`list_projects`), and repos (`list_repositories`) to target “your-org/AppDev/MainApp.” +2. **Create User Story**: AI creates a user story (`create_work_item`). +3. **Branch and Code**: AI creates a branch (`create_branch`), writes code (`create_or_update_file`), and pushes (`push_changes`). +4. **Pipeline**: AI triggers a build (`trigger_pipeline`) and checks status (`get_pipeline_status`). +5. **Pull Request**: AI submits a PR (`create_pull_request`) linked to the user story. +6. **Board Update**: AI moves the user story to “In Review” (`update_board`). 7. **Merge**: AI merges the PR (`merge_pull_request`) and closes the user story (`update_work_item`). --- @@ -116,25 +129,30 @@ Here’s the updated toolset, now including a **Core Functionality** section, wi ### Detailed Breakdown of Core Functionality #### Tools and API Mapping -- `list_organizations`: `GET /organizations` (requires Azure Identity or OAuth for full access). -- `list_projects`: `GET /{organization}/_apis/projects`. -- `list_repositories`: `GET /{organization}/{project}/_apis/git/repositories`. -- `get_project_details`: `GET /{organization}/_apis/projects/{projectId}`. + +- `list_organizations`: `GET /organizations` (requires Azure Identity or OAuth for full access). +- `list_projects`: `GET /{organization}/_apis/projects`. +- `list_repositories`: `GET /{organization}/{project}/_apis/git/repositories`. +- `get_project_details`: `GET /{organization}/_apis/projects/{projectId}`. - `get_repository_details`: `GET /{organization}/{project}/_apis/git/repositories/{repositoryId}`. #### Common Use Cases -- **Project Selection**: AI lists projects to pick the right one for a task (e.g., “AppDev” for app-related work). -- **Repo Discovery**: AI finds repos to determine where code should go (e.g., “MainApp” vs. “Tests”). + +- **Project Selection**: AI lists projects to pick the right one for a task (e.g., “AppDev” for app-related work). +- **Repo Discovery**: AI finds repos to determine where code should go (e.g., “MainApp” vs. “Tests”). - **Context Gathering**: AI fetches project/repo details to understand team settings or branching strategies. #### Integration with Workflow -The core tools are the starting point for the end-to-end use case, ensuring the AI knows *where* to act before *what* to do. For example, `list_projects` narrows down to “AppDev,” and `list_repositories` confirms “MainApp” as the target. + +The core tools are the starting point for the end-to-end use case, ensuring the AI knows _where_ to act before _what_ to do. For example, `list_projects` narrows down to “AppDev,” and `list_repositories` confirms “MainApp” as the target. --- ### Next Steps -1. **Finalize Toolset**: Confirm the 30–35 tools, ensuring all Azure DevOps areas are covered. + +1. **Finalize Toolset**: Confirm the 30–35 tools, ensuring all Azure DevOps areas are covered. 2. **Prototype**: Build a minimal server with core tools (e.g., `list_projects`, `create_work_item`) using this skeleton: + ```typescript import { getMcpServer } from '@modelcontextprotocol/sdk'; import { WebApi } from 'azure-devops-node-api'; @@ -143,22 +161,35 @@ The core tools are the starting point for the end-to-end use case, ensuring the name: 'azuredevops', tools: { list_projects: async () => { - const api = new WebApi(`https://dev.azure.com/${process.env.AZURE_DEVOPS_ORG}`, process.env.AZURE_DEVOPS_PAT); + const api = new WebApi( + `https://dev.azure.com/${process.env.AZURE_DEVOPS_ORG}`, + process.env.AZURE_DEVOPS_PAT, + ); const coreApi = await api.getCoreApi(); return coreApi.getProjects(); }, create_work_item: async (params) => { - const api = new WebApi(`https://dev.azure.com/${process.env.AZURE_DEVOPS_ORG}`, process.env.AZURE_DEVOPS_PAT); + const api = new WebApi( + `https://dev.azure.com/${process.env.AZURE_DEVOPS_ORG}`, + process.env.AZURE_DEVOPS_PAT, + ); const witApi = await api.getWorkItemTrackingApi(); - return witApi.createWorkItem({}, params, process.env.AZURE_DEVOPS_PROJECT, 'User Story'); + return witApi.createWorkItem( + {}, + params, + process.env.AZURE_DEVOPS_PROJECT, + 'User Story', + ); }, }, }); ``` -3. **Test**: Use a test Azure DevOps instance to validate core functionality and workflow. + +3. **Test**: Use a test Azure DevOps instance to validate core functionality and workflow. 4. **Expand**: Add remaining tools (e.g., pipeline, board) iteratively. --- ### Feedback + The addition of **Core Functionality** ensures the server is both navigational and operational, perfectly supporting your vision. Do you want to prioritize any specific core tools (e.g., `list_projects`) or jump into coding a prototype? I can refine further or provide a step-by-step build guide next! diff --git a/project-management/planning/project-plan.md b/project-management/planning/project-plan.md index fd3f078..d2e7e95 100644 --- a/project-management/planning/project-plan.md +++ b/project-management/planning/project-plan.md @@ -5,9 +5,11 @@ Below is a comprehensive project plan for building an Azure DevOps MCP (Model Co ## Project Plan: Building an Azure DevOps MCP Server ### Objective + Develop a reference server for the Model Context Protocol (MCP) that integrates with Azure DevOps. The server will mirror the functionality of the GitHub MCP server, tailored for Azure DevOps, using Typescript and the MCP SDK, and support an end-to-end AI-driven workflow from user story creation to pull request merging. ### Scope + - **Functionalities**: Implement tools in six key areas: - Core Functionality (e.g., listing organizations, projects, repositories) - Repository Operations (e.g., creating/updating files, pushing changes) @@ -20,6 +22,7 @@ Develop a reference server for the Model Context Protocol (MCP) that integrates - **Documentation**: Provide setup instructions, tool descriptions, usage examples, and troubleshooting guidance. ### Success Criteria + - The server passes all unit and integration tests. - It supports the end-to-end use case (user story to pull request). - It is secure, performant, and adheres to best practices. @@ -30,6 +33,7 @@ Develop a reference server for the Model Context Protocol (MCP) that integrates ## Project Features and Tasks ### 1. Planning and Setup + - **Tasks**: - Define project scope, requirements, and success criteria. - Set up Azure Boards for task tracking. @@ -44,6 +48,7 @@ Develop a reference server for the Model Context Protocol (MCP) that integrates - Development environment ready with dependencies installed. ### 2. Research and Design + - **Tasks**: - Finalize the list of tools to implement, including inputs and outputs. - Design the server architecture (e.g., tool organization, API integration). @@ -54,29 +59,36 @@ Develop a reference server for the Model Context Protocol (MCP) that integrates - Authentication design document. ### 3. Development + The development focuses on implementing the tools, organized by functional area. Each area includes implementation and unit testing. - **Core Functionality** + - Tools: `list_organizations`, `list_projects`, `list_repositories`, etc. - Objective: Enable basic navigation within Azure DevOps. - **Work Item Management** + - Tools: `create_work_item`, `update_work_item`, `list_work_items`, etc. - Objective: Support user story and task management. - **Repository Operations** + - Tools: `create_or_update_file`, `push_changes`, `get_file_contents`, etc. - Objective: Allow code reading and writing. - **Branch and Pull Request Management** + - Tools: `create_branch`, `create_pull_request`, `merge_pull_request`, etc. - Objective: Manage branches and pull requests. - **Pipeline Interactions** + - Tools: `trigger_pipeline`, `get_pipeline_status`, `list_pipelines`, etc. - Objective: Integrate with CI/CD pipelines. - **Search and Query** + - Tools: `search_code`, `search_work_items`, `search_wiki`, etc. - Objective: Enable search across Azure DevOps entities. @@ -85,6 +97,7 @@ The development focuses on implementing the tools, organized by functional area. - Updated tool documentation. ### 4. Integration and Testing + - **Tasks**: - Integrate all tools into a cohesive server. - Conduct integration testing for the end-to-end use case. @@ -96,6 +109,7 @@ The development focuses on implementing the tools, organized by functional area. - Bug fixes and optimizations. ### 5. Documentation and Release + - **Tasks**: - Write setup and usage documentation (e.g., README.md). - Create usage examples and tutorials (e.g., integration with MCP clients). @@ -110,16 +124,18 @@ The development focuses on implementing the tools, organized by functional area. ## Key Considerations ### Risks and Mitigation + - **Risk**: Delays due to unfamiliarity with MCP or Azure DevOps APIs. - - *Mitigation*: Allocate research time and provide team training. + - _Mitigation_: Allocate research time and provide team training. - **Risk**: Authentication complexities (e.g., AAD integration). - - *Mitigation*: Prioritize PAT support, implement AAD as a secondary goal. + - _Mitigation_: Prioritize PAT support, implement AAD as a secondary goal. - **Risk**: Security vulnerabilities. - - *Mitigation*: Use secure credential storage, minimal permissions, and thorough testing. + - _Mitigation_: Use secure credential storage, minimal permissions, and thorough testing. - **Risk**: Performance issues with large datasets. - - *Mitigation*: Implement pagination and optimize API calls. + - _Mitigation_: Implement pagination and optimize API calls. ### Team and Resources + - **Team**: 2-3 developers familiar with Typescript and REST APIs. - **Tools**: - Development: VS Code, Node.js, Typescript. @@ -128,10 +144,12 @@ The development focuses on implementing the tools, organized by functional area. - **Access**: Azure DevOps instance for development and testing. ### Version Control and CI/CD + - **Git**: Host the repository on GitHub or Azure DevOps. - **CI/CD**: Use Azure DevOps Pipelines for automated builds and testing. ### Documentation + - **Content**: - Installation and configuration instructions. - Authentication setup (PAT and AAD). @@ -141,12 +159,14 @@ The development focuses on implementing the tools, organized by functional area. - **Format**: README.md in the repository, supplemented by examples folder. ### Release + - **Versioning**: Use semantic versioning (start at 0.1.0). - **Distribution**: Publish via GitHub releases or npm. --- ## Milestones and Deliverables + - **Planning and Setup**: Repository, CI/CD pipeline, and environment ready. - **Research and Design**: Tool list, architecture, and authentication plan finalized. - **Development**: Functional tools with unit tests. diff --git a/project-management/planning/project-structure.md b/project-management/planning/project-structure.md index a4b9e26..705d624 100644 --- a/project-management/planning/project-structure.md +++ b/project-management/planning/project-structure.md @@ -81,4 +81,4 @@ azure-devops-mcp-server/ - **`docs/`**: Stores documentation, including examples and troubleshooting guides, to support users and developers. - **Root Files**: Configuration files (`tsconfig.json`, `jest.config.js`, etc.), `README.md`, and `LICENSE` reside at the root for visibility and convention. -This structure ensures the project is scalable, maintainable, and intuitive to navigate, accommodating future growth while keeping components modular and well-separated. \ No newline at end of file +This structure ensures the project is scalable, maintainable, and intuitive to navigate, accommodating future growth while keeping components modular and well-separated. diff --git a/project-management/planning/tech-stack.md b/project-management/planning/tech-stack.md index e2cbea8..6b0905a 100644 --- a/project-management/planning/tech-stack.md +++ b/project-management/planning/tech-stack.md @@ -1,13 +1,16 @@ ## Tech Stack Documentation ### Overview + The tech stack for the Azure DevOps MCP server is tailored to ensure compatibility with the MCP, efficient interaction with Azure DevOps APIs, and a focus on security and scalability. It comprises a mix of programming languages, runtime environments, libraries, and development tools that streamline server development and operation. ### Programming Language and Runtime + - **Typescript**: Selected for its type safety, which minimizes runtime errors and enhances code readability. It aligns seamlessly with the MCP Typescript SDK for easy integration. - **Node.js**: The runtime environment for executing Typescript, offering a non-blocking, event-driven architecture ideal for handling multiple API requests efficiently. ### Libraries and Dependencies + - **MCP Typescript SDK**: The official SDK for MCP server development. It provides the `getMcpServer` function to define and run the server with minimal setup, managing socket connections and JSON-RPC messaging so developers can focus on tool logic. - **azure-devops-node-api**: A Node.js library that simplifies interaction with Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). It supports Personal Access Token (PAT) authentication and offers a straightforward interface for common tasks. - **Axios**: A promise-based HTTP client for raw API requests, particularly useful for endpoints not covered by `azure-devops-node-api` (e.g., listing organizations or Search API). It also supports Azure Active Directory (AAD) token-based authentication. @@ -15,18 +18,20 @@ The tech stack for the Azure DevOps MCP server is tailored to ensure compatibili - **dotenv**: A lightweight module for loading environment variables from a `.env` file, securely managing sensitive data like PATs and AAD credentials. ### Development Tools + - **Visual Studio Code (VS Code)**: The recommended IDE, offering robust Typescript support, debugging tools, and integration with Git and Azure DevOps. - **npm**: The package manager for installing and managing project dependencies. - **ts-node**: Enables direct execution of Typescript files without precompilation, accelerating development and testing workflows. ### Testing and Quality Assurance + - **Jest**: A widely-used testing framework for unit and integration tests, ensuring the reliability of tools and server functionality. - **ESLint**: A linter configured with Typescript-specific rules to maintain code quality and consistency. - **Prettier**: A code formatter to enforce a uniform style across the project. ### Version Control and CI/CD + - **Git**: Used for version control, with repositories hosted on GitHub or Azure DevOps. - **GitHub Actions**: Automates continuous integration and deployment, including builds, tests, and releases. --- - diff --git a/project-management/planning/the-dream-team.md b/project-management/planning/the-dream-team.md index ac49a2b..444b4de 100644 --- a/project-management/planning/the-dream-team.md +++ b/project-management/planning/the-dream-team.md @@ -5,12 +5,14 @@ Below is the **Dream Team Documentation** for building the Azure DevOps MCP serv ## Dream Team Documentation: Building the Azure DevOps MCP Server ### Overview + 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. ### Key Roles and Responsibilities #### 1. **Full-Stack Developer (Typescript/Node.js)** -- **Responsibilities**: + +- **Responsibilities**: - Implement the server's core functionality using Typescript and Node.js. - Develop and maintain MCP tools (e.g., `list_projects`, `create_work_item`). - Write tests as part of the implementation process (TDD). @@ -19,7 +21,7 @@ The Azure DevOps MCP server is a complex tool that requires a multidisciplinary - Ensure code quality through comprehensive unit and integration tests. - Build automated testing pipelines for continuous integration. - Perform integration testing across components. -- **Required Skills**: +- **Required Skills**: - Proficiency in Typescript and Node.js. - Strong testing skills and experience with test frameworks (e.g., Jest). - Experience writing testable code and following TDD practices. @@ -29,83 +31,91 @@ The Azure DevOps MCP server is a complex tool that requires a multidisciplinary - Experience with API testing and mocking tools. #### 2. **Azure DevOps API Expert** -- **Responsibilities**: - - Guide the team on effectively using Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). - - Ensure the server leverages Azure DevOps features optimally (e.g., repository operations, pipelines). - - Assist in mapping MCP tools to the correct API endpoints. + +- **Responsibilities**: + - Guide the team on effectively using Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). + - Ensure the server leverages Azure DevOps features optimally (e.g., repository operations, pipelines). + - Assist in mapping MCP tools to the correct API endpoints. - Troubleshoot API-related issues and optimize API usage. - Help develop tests for Azure DevOps API integrations. -- **Required Skills**: - - Deep understanding of Azure DevOps services and their REST APIs. - - Experience with Azure DevOps workflows (e.g., repositories, work items, pipelines). - - Knowledge of Azure DevOps authentication mechanisms (PAT, AAD). +- **Required Skills**: + - Deep understanding of Azure DevOps services and their REST APIs. + - Experience with Azure DevOps workflows (e.g., repositories, work items, pipelines). + - Knowledge of Azure DevOps authentication mechanisms (PAT, AAD). - Ability to interpret API documentation and handle rate limits. - Experience testing API integrations. #### 3. **Security Specialist** -- **Responsibilities**: - - Design and implement secure authentication methods (PAT and AAD). - - Ensure credentials are stored and managed securely (e.g., environment variables). - - Scope permissions to the minimum required for each tool. - - Implement error handling and logging without exposing sensitive data. + +- **Responsibilities**: + - Design and implement secure authentication methods (PAT and AAD). + - Ensure credentials are stored and managed securely (e.g., environment variables). + - Scope permissions to the minimum required for each tool. + - Implement error handling and logging without exposing sensitive data. - Conduct security reviews and recommend improvements. - Develop security tests and validation procedures. -- **Required Skills**: - - Expertise in API security, authentication, and authorization. - - Familiarity with Azure Active Directory and PAT management. - - Knowledge of secure coding practices and vulnerability prevention. +- **Required Skills**: + - Expertise in API security, authentication, and authorization. + - Familiarity with Azure Active Directory and PAT management. + - Knowledge of secure coding practices and vulnerability prevention. - Experience with logging, auditing, and compliance. - Experience with security testing tools and methodologies. #### 4. **Technical Writer** -- **Responsibilities**: - - Create comprehensive documentation, including setup guides, tool descriptions, and usage examples. - - Write clear API references and troubleshooting tips. - - Ensure documentation is accessible to both technical and non-technical users. - - Maintain up-to-date documentation as the server evolves. -- **Required Skills**: - - Strong technical writing and communication skills. - - Ability to explain complex concepts simply. - - Experience documenting APIs and developer tools. + +- **Responsibilities**: + - Create comprehensive documentation, including setup guides, tool descriptions, and usage examples. + - Write clear API references and troubleshooting tips. + - Ensure documentation is accessible to both technical and non-technical users. + - Maintain up-to-date documentation as the server evolves. +- **Required Skills**: + - Strong technical writing and communication skills. + - Ability to explain complex concepts simply. + - Experience documenting APIs and developer tools. - Familiarity with Markdown and documentation platforms (e.g., GitHub README). #### 5. **Project Manager** -- **Responsibilities**: - - Coordinate the team's efforts and manage the project timeline. - - Track progress using Azure Boards or similar tools. - - Facilitate communication and resolve blockers. - - Ensure the project stays on scope and meets deadlines. - - Manage stakeholder expectations and provide status updates. -- **Required Skills**: - - Experience in agile project management. - - Proficiency with project tracking tools (e.g., Azure Boards, Jira). - - Strong organizational and leadership skills. + +- **Responsibilities**: + - Coordinate the team's efforts and manage the project timeline. + - Track progress using Azure Boards or similar tools. + - Facilitate communication and resolve blockers. + - Ensure the project stays on scope and meets deadlines. + - Manage stakeholder expectations and provide status updates. +- **Required Skills**: + - Experience in agile project management. + - Proficiency with project tracking tools (e.g., Azure Boards, Jira). + - Strong organizational and leadership skills. - Ability to manage remote or distributed teams. #### 6. **AI Integration Consultant** -- **Responsibilities**: - - Advise on how the server can best integrate with AI models (e.g., Claude Desktop). - - Ensure tools are designed to support AI-driven workflows (e.g., user story to pull request). - - Provide insights into MCP's AI integration capabilities. - - Assist in testing AI interactions with the server. -- **Required Skills**: - - Experience with AI model integration and workflows. - - Understanding of the Model Context Protocol (MCP). - - Familiarity with AI tools like Claude Desktop. + +- **Responsibilities**: + - Advise on how the server can best integrate with AI models (e.g., Claude Desktop). + - Ensure tools are designed to support AI-driven workflows (e.g., user story to pull request). + - Provide insights into MCP's AI integration capabilities. + - Assist in testing AI interactions with the server. +- **Required Skills**: + - Experience with AI model integration and workflows. + - Understanding of the Model Context Protocol (MCP). + - Familiarity with AI tools like Claude Desktop. - Ability to bridge AI and software development domains. --- ### Team Structure and Collaboration -- **Core Team**: Full-Stack Developer, Azure DevOps API Expert, Security Specialist. -- **Support Roles**: Technical Writer, Project Manager, AI Integration Consultant. -- **Collaboration**: Use Agile methodologies with bi-weekly sprints, daily stand-ups, and regular retrospectives to iterate efficiently. + +- **Core Team**: Full-Stack Developer, Azure DevOps API Expert, Security Specialist. +- **Support Roles**: Technical Writer, Project Manager, AI Integration Consultant. +- **Collaboration**: Use Agile methodologies with bi-weekly sprints, daily stand-ups, and regular retrospectives to iterate efficiently. - **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. --- ### Why This Team? + Each role addresses a critical aspect of the project: + - The **Full-Stack Developer** builds the server using modern technologies like Typescript and Node.js, integrating testing throughout the development process. - The **Azure DevOps API Expert** ensures seamless integration with Azure DevOps services. - The **Security Specialist** safeguards the server against vulnerabilities. diff --git a/project-management/reference/mcp-server/README.md b/project-management/reference/mcp-server/README.md index cbe0dcb..fc58c92 100644 --- a/project-management/reference/mcp-server/README.md +++ b/project-management/reference/mcp-server/README.md @@ -1,6 +1,6 @@ # Model Context Protocol servers -This repository is a collection of *reference implementations* for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), as well as references +This repository is a collection of _reference implementations_ for the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP), as well as references to community built servers and additional resources. The servers in this repository showcase the versatility and extensibility of MCP, demonstrating how it can be used to give Large Language Models (LLMs) secure, controlled access to tools and data sources. @@ -50,7 +50,7 @@ Official integrations are maintained by companies building production ready MCP - Fireproof Logo **[Fireproof](https://github.com/fireproof-storage/mcp-database-server)** - Immutable ledger database with live synchronization - Grafana Logo **[Grafana](https://github.com/grafana/mcp-grafana)** - Search dashboards, investigate incidents and query datasources in your Grafana instance - **[IBM wxflows](https://github.com/IBM/wxflows/tree/main/examples/mcp/javascript)** - Tool platform by IBM to build, test and deploy tools for any data source -- Integration App Icon **[Integration App](https://github.com/integration-app/mcp-server)** - Interact with any other SaaS applications on behalf of your customers. +- Integration App Icon **[Integration App](https://github.com/integration-app/mcp-server)** - Interact with any other SaaS applications on behalf of your customers. - **[JetBrains](https://github.com/JetBrains/mcp-jetbrains)** – Work on your code with JetBrains IDEs - Kagi Logo **[Kagi Search](https://github.com/kagisearch/kagimcp)** - Search the web using Kagi's search API - Lingo.dev Logo **[Lingo.dev](https://github.com/lingodotdev/lingo.dev/blob/main/mcp.md)** - Make your AI agent speak every language on the planet, using [Lingo.dev](https://lingo.dev) Localization Engine. @@ -209,14 +209,14 @@ These are high-level frameworks that make it easier to build MCP servers or clie ### For servers -* **[EasyMCP](https://github.com/zcaceres/easy-mcp/)** (TypeScript) -* **[FastMCP](https://github.com/punkpeye/fastmcp)** (TypeScript) -* **[Foxy Contexts](https://github.com/strowk/foxy-contexts)** – A library to build MCP servers in Golang by **[strowk](https://github.com/strowk)** -* **[Quarkus MCP Server SDK](https://github.com/quarkiverse/quarkus-mcp-server)** (Java) +- **[EasyMCP](https://github.com/zcaceres/easy-mcp/)** (TypeScript) +- **[FastMCP](https://github.com/punkpeye/fastmcp)** (TypeScript) +- **[Foxy Contexts](https://github.com/strowk/foxy-contexts)** – A library to build MCP servers in Golang by **[strowk](https://github.com/strowk)** +- **[Quarkus MCP Server SDK](https://github.com/quarkiverse/quarkus-mcp-server)** (Java) ### For clients -* **[codemirror-mcp](https://github.com/marimo-team/codemirror-mcp)** - CodeMirror extension that implements the Model Context Protocol (MCP) for resource mentions and prompt commands +- **[codemirror-mcp](https://github.com/marimo-team/codemirror-mcp)** - CodeMirror extension that implements the Model Context Protocol (MCP) for resource mentions and prompt commands ## 📚 Resources @@ -246,9 +246,11 @@ Additional resources on MCP. ## 🚀 Getting Started ### Using MCP Servers in this Repository + Typescript-based servers in this repository can be used directly with `npx`. For example, this will start the [Memory](src/memory) server: + ```sh npx -y @modelcontextprotocol/server-memory ``` @@ -256,6 +258,7 @@ npx -y @modelcontextprotocol/server-memory Python-based servers in this repository can be used directly with [`uvx`](https://docs.astral.sh/uv/concepts/tools/) or [`pip`](https://pypi.org/project/pip/). `uvx` is recommended for ease of use and setup. For example, this will start the [Git](src/git) server: + ```sh # With uvx uvx mcp-server-git @@ -268,6 +271,7 @@ python -m mcp_server_git Follow [these](https://docs.astral.sh/uv/getting-started/installation/) instructions to install `uv` / `uvx` and [these](https://pip.pypa.io/en/stable/installation/) to install `pip`. ### Using an MCP Client + However, running a server on its own isn't very useful, and should instead be configured into an MCP client. For example, here's the Claude Desktop configuration to use the above server: ```json @@ -288,7 +292,11 @@ Additional examples of using the Claude Desktop as an MCP client might look like "mcpServers": { "filesystem": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"] + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/path/to/allowed/files" + ] }, "git": { "command": "uvx", @@ -303,7 +311,11 @@ Additional examples of using the Claude Desktop as an MCP client might look like }, "postgres": { "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"] + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://localhost/mydb" + ] } } } diff --git a/project-management/reference/mcp-server/src/github/README.md b/project-management/reference/mcp-server/src/github/README.md index 026dde9..080cf48 100644 --- a/project-management/reference/mcp-server/src/github/README.md +++ b/project-management/reference/mcp-server/src/github/README.md @@ -10,10 +10,10 @@ MCP Server for the GitHub API, enabling file operations, repository management, - **Batch Operations**: Support for both single-file and multi-file operations - **Advanced Search**: Support for searching code, issues/PRs, and users - ## Tools 1. `create_or_update_file` + - Create or update a single file in a repository - Inputs: - `owner` (string): Repository owner (username or organization) @@ -26,6 +26,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: File content and commit details 2. `push_files` + - Push multiple files in a single commit - Inputs: - `owner` (string): Repository owner @@ -36,6 +37,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Updated branch reference 3. `search_repositories` + - Search for GitHub repositories - Inputs: - `query` (string): Search query @@ -44,6 +46,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Repository search results 4. `create_repository` + - Create a new GitHub repository - Inputs: - `name` (string): Repository name @@ -53,6 +56,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Created repository details 5. `get_file_contents` + - Get contents of a file or directory - Inputs: - `owner` (string): Repository owner @@ -62,6 +66,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: File/directory contents 6. `create_issue` + - Create a new issue - Inputs: - `owner` (string): Repository owner @@ -74,6 +79,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Created issue details 7. `create_pull_request` + - Create a new pull request - Inputs: - `owner` (string): Repository owner @@ -87,6 +93,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Created pull request details 8. `fork_repository` + - Fork a repository - Inputs: - `owner` (string): Repository owner @@ -95,6 +102,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Forked repository details 9. `create_branch` + - Create a new branch - Inputs: - `owner` (string): Repository owner @@ -104,6 +112,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Created branch reference 10. `list_issues` + - List and filter repository issues - Inputs: - `owner` (string): Repository owner @@ -118,6 +127,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Array of issue details 11. `update_issue` + - Update an existing issue - Inputs: - `owner` (string): Repository owner @@ -132,6 +142,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Updated issue details 12. `add_issue_comment` + - Add a comment to an issue - Inputs: - `owner` (string): Repository owner @@ -141,6 +152,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Created comment details 13. `search_code` + - Search for code across GitHub repositories - Inputs: - `q` (string): Search query using GitHub code search syntax @@ -151,6 +163,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Code search results with repository context 14. `search_issues` + - Search for issues and pull requests - Inputs: - `q` (string): Search query using GitHub issues search syntax @@ -161,6 +174,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: Issue and pull request search results 15. `search_users` + - Search for GitHub users - Inputs: - `q` (string): Search query using GitHub users search syntax @@ -171,115 +185,127 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Returns: User search results 16. `list_commits` - - Gets commits of a branch in a repository - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `page` (optional string): page number - - `per_page` (optional string): number of record per page - - `sha` (optional string): branch name - - Returns: List of commits + +- Gets commits of a branch in a repository +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `page` (optional string): page number + - `per_page` (optional string): number of record per page + - `sha` (optional string): branch name +- Returns: List of commits 17. `get_issue` - - Gets the contents of an issue within a repository - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `issue_number` (number): Issue number to retrieve - - Returns: Github Issue object & details + +- Gets the contents of an issue within a repository +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `issue_number` (number): Issue number to retrieve +- Returns: Github Issue object & details 18. `get_pull_request` - - Get details of a specific pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - Returns: Pull request details including diff and review status + +- Get details of a specific pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number +- Returns: Pull request details including diff and review status 19. `list_pull_requests` - - List and filter repository pull requests - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `state` (optional string): Filter by state ('open', 'closed', 'all') - - `head` (optional string): Filter by head user/org and branch - - `base` (optional string): Filter by base branch - - `sort` (optional string): Sort by ('created', 'updated', 'popularity', 'long-running') - - `direction` (optional string): Sort direction ('asc', 'desc') - - `per_page` (optional number): Results per page (max 100) - - `page` (optional number): Page number - - Returns: Array of pull request details + +- List and filter repository pull requests +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `state` (optional string): Filter by state ('open', 'closed', 'all') + - `head` (optional string): Filter by head user/org and branch + - `base` (optional string): Filter by base branch + - `sort` (optional string): Sort by ('created', 'updated', 'popularity', 'long-running') + - `direction` (optional string): Sort direction ('asc', 'desc') + - `per_page` (optional number): Results per page (max 100) + - `page` (optional number): Page number +- Returns: Array of pull request details 20. `create_pull_request_review` - - Create a review on a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - `body` (string): Review comment text - - `event` (string): Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') - - `commit_id` (optional string): SHA of commit to review - - `comments` (optional array): Line-specific comments, each with: - - `path` (string): File path - - `position` (number): Line position in diff - - `body` (string): Comment text - - Returns: Created review details + +- Create a review on a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `body` (string): Review comment text + - `event` (string): Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') + - `commit_id` (optional string): SHA of commit to review + - `comments` (optional array): Line-specific comments, each with: + - `path` (string): File path + - `position` (number): Line position in diff + - `body` (string): Comment text +- Returns: Created review details 21. `merge_pull_request` - - Merge a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - `commit_title` (optional string): Title for merge commit - - `commit_message` (optional string): Extra detail for merge commit - - `merge_method` (optional string): Merge method ('merge', 'squash', 'rebase') - - Returns: Merge result details + +- Merge a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `commit_title` (optional string): Title for merge commit + - `commit_message` (optional string): Extra detail for merge commit + - `merge_method` (optional string): Merge method ('merge', 'squash', 'rebase') +- Returns: Merge result details 22. `get_pull_request_files` - - Get the list of files changed in a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - Returns: Array of changed files with patch and status details + +- Get the list of files changed in a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number +- Returns: Array of changed files with patch and status details 23. `get_pull_request_status` - - Get the combined status of all status checks for a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - Returns: Combined status check results and individual check details + +- Get the combined status of all status checks for a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number +- Returns: Combined status check results and individual check details 24. `update_pull_request_branch` - - Update a pull request branch with the latest changes from the base branch (equivalent to GitHub's "Update branch" button) - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - `expected_head_sha` (optional string): The expected SHA of the pull request's HEAD ref - - Returns: Success message when branch is updated + +- Update a pull request branch with the latest changes from the base branch (equivalent to GitHub's "Update branch" button) +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `expected_head_sha` (optional string): The expected SHA of the pull request's HEAD ref +- Returns: Success message when branch is updated 25. `get_pull_request_comments` - - Get the review comments on a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - Returns: Array of pull request review comments with details like the comment text, author, and location in the diff + +- Get the review comments on a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number +- Returns: Array of pull request review comments with details like the comment text, author, and location in the diff 26. `get_pull_request_reviews` - - Get the reviews on a pull request - - Inputs: - - `owner` (string): Repository owner - - `repo` (string): Repository name - - `pull_number` (number): Pull request number - - Returns: Array of pull request reviews with details like the review state (APPROVED, CHANGES_REQUESTED, etc.), reviewer, and review body + +- Get the reviews on a pull request +- Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number +- Returns: Array of pull request reviews with details like the review state (APPROVED, CHANGES_REQUESTED, etc.), reviewer, and review body ## Search Query Syntax ### Code Search + - `language:javascript`: Search by programming language - `repo:owner/name`: Search in specific repository - `path:app/src`: Search in specific path @@ -287,6 +313,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Example: `q: "import express" language:typescript path:src/` ### Issues Search + - `is:issue` or `is:pr`: Filter by type - `is:open` or `is:closed`: Filter by state - `label:bug`: Search by label @@ -294,6 +321,7 @@ MCP Server for the GitHub API, enabling file operations, repository management, - Example: `q: "memory leak" is:issue is:open label:bug` ### Users Search + - `type:user` or `type:org`: Filter by account type - `followers:>1000`: Filter by followers - `location:London`: Search by location @@ -304,17 +332,21 @@ For detailed search syntax, see [GitHub's searching documentation](https://docs. ## Setup ### Personal Access Token + [Create a GitHub Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) with appropriate permissions: - - Go to [Personal access tokens](https://github.com/settings/tokens) (in GitHub Settings > Developer settings) - - Select which repositories you'd like this token to have access to (Public, All, or Select) - - Create a token with the `repo` scope ("Full control of private repositories") - - Alternatively, if working only with public repositories, select only the `public_repo` scope - - Copy the generated token + +- Go to [Personal access tokens](https://github.com/settings/tokens) (in GitHub Settings > Developer settings) +- Select which repositories you'd like this token to have access to (Public, All, or Select) +- Create a token with the `repo` scope ("Full control of private repositories") + - Alternatively, if working only with public repositories, select only the `public_repo` scope +- Copy the generated token ### Usage with Claude Desktop + To use this with Claude Desktop, add the following to your `claude_desktop_config.json`: #### Docker + ```json { "mcpServers": { @@ -343,10 +375,7 @@ To use this with Claude Desktop, add the following to your `claude_desktop_confi "mcpServers": { "github": { "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-github" - ], + "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" } diff --git a/project-management/reference/mcp-server/src/github/common/errors.ts b/project-management/reference/mcp-server/src/github/common/errors.ts index 5b940f3..df26c0c 100644 --- a/project-management/reference/mcp-server/src/github/common/errors.ts +++ b/project-management/reference/mcp-server/src/github/common/errors.ts @@ -2,55 +2,57 @@ export class GitHubError extends Error { constructor( message: string, public readonly status: number, - public readonly response: unknown + public readonly response: unknown, ) { super(message); - this.name = "GitHubError"; + this.name = 'GitHubError'; } } export class GitHubValidationError extends GitHubError { constructor(message: string, status: number, response: unknown) { super(message, status, response); - this.name = "GitHubValidationError"; + this.name = 'GitHubValidationError'; } } export class GitHubResourceNotFoundError extends GitHubError { constructor(resource: string) { - super(`Resource not found: ${resource}`, 404, { message: `${resource} not found` }); - this.name = "GitHubResourceNotFoundError"; + super(`Resource not found: ${resource}`, 404, { + message: `${resource} not found`, + }); + this.name = 'GitHubResourceNotFoundError'; } } export class GitHubAuthenticationError extends GitHubError { - constructor(message = "Authentication failed") { + constructor(message = 'Authentication failed') { super(message, 401, { message }); - this.name = "GitHubAuthenticationError"; + this.name = 'GitHubAuthenticationError'; } } export class GitHubPermissionError extends GitHubError { - constructor(message = "Insufficient permissions") { + constructor(message = 'Insufficient permissions') { super(message, 403, { message }); - this.name = "GitHubPermissionError"; + this.name = 'GitHubPermissionError'; } } export class GitHubRateLimitError extends GitHubError { constructor( - message = "Rate limit exceeded", - public readonly resetAt: Date + message = 'Rate limit exceeded', + public readonly resetAt: Date, ) { super(message, 429, { message, reset_at: resetAt.toISOString() }); - this.name = "GitHubRateLimitError"; + this.name = 'GitHubRateLimitError'; } } export class GitHubConflictError extends GitHubError { constructor(message: string) { super(message, 409, { message }); - this.name = "GitHubConflictError"; + this.name = 'GitHubConflictError'; } } @@ -65,25 +67,25 @@ export function createGitHubError(status: number, response: any): GitHubError { case 403: return new GitHubPermissionError(response?.message); case 404: - return new GitHubResourceNotFoundError(response?.message || "Resource"); + return new GitHubResourceNotFoundError(response?.message || 'Resource'); case 409: - return new GitHubConflictError(response?.message || "Conflict occurred"); + return new GitHubConflictError(response?.message || 'Conflict occurred'); case 422: return new GitHubValidationError( - response?.message || "Validation failed", + response?.message || 'Validation failed', status, - response + response, ); case 429: return new GitHubRateLimitError( response?.message, - new Date(response?.reset_at || Date.now() + 60000) + new Date(response?.reset_at || Date.now() + 60000), ); default: return new GitHubError( - response?.message || "GitHub API error", + response?.message || 'GitHub API error', status, - response + response, ); } -} \ No newline at end of file +} diff --git a/project-management/reference/mcp-server/src/github/common/types.ts b/project-management/reference/mcp-server/src/github/common/types.ts index cca961b..b7fa0c7 100644 --- a/project-management/reference/mcp-server/src/github/common/types.ts +++ b/project-management/reference/mcp-server/src/github/common/types.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { z } from 'zod'; // Base schemas for common types export const GitHubAuthorSchema = z.object({ @@ -40,7 +40,7 @@ export const GitHubRepositorySchema = z.object({ export const GithubFileContentLinks = z.object({ self: z.string(), git: z.string().nullable(), - html: z.string().nullable() + html: z.string().nullable(), }); export const GitHubFileContentSchema = z.object({ @@ -55,7 +55,7 @@ export const GitHubFileContentSchema = z.object({ type: z.string(), content: z.string().optional(), encoding: z.string().optional(), - _links: GithubFileContentLinks + _links: GithubFileContentLinks, }); export const GitHubDirectoryContentSchema = z.object({ @@ -77,8 +77,8 @@ export const GitHubContentSchema = z.union([ export const GitHubTreeEntrySchema = z.object({ path: z.string(), - mode: z.enum(["100644", "100755", "040000", "160000", "120000"]), - type: z.enum(["blob", "tree", "commit"]), + mode: z.enum(['100644', '100755', '040000', '160000', '120000']), + type: z.enum(['blob', 'tree', 'commit']), size: z.number().optional(), sha: z.string(), url: z.string(), @@ -106,28 +106,30 @@ export const GitHubCommitSchema = z.object({ z.object({ sha: z.string(), url: z.string(), - }) + }), ), }); -export const GitHubListCommitsSchema = z.array(z.object({ - sha: z.string(), - node_id: z.string(), - commit: z.object({ - author: GitHubAuthorSchema, - committer: GitHubAuthorSchema, - message: z.string(), - tree: z.object({ - sha: z.string(), - url: z.string() +export const GitHubListCommitsSchema = z.array( + z.object({ + sha: z.string(), + node_id: z.string(), + commit: z.object({ + author: GitHubAuthorSchema, + committer: GitHubAuthorSchema, + message: z.string(), + tree: z.object({ + sha: z.string(), + url: z.string(), + }), + url: z.string(), + comment_count: z.number(), }), url: z.string(), - comment_count: z.number(), + html_url: z.string(), + comments_url: z.string(), }), - url: z.string(), - html_url: z.string(), - comments_url: z.string() -})); +); export const GitHubReferenceSchema = z.object({ ref: z.string(), @@ -244,7 +246,9 @@ export const GitHubPullRequestSchema = z.object({ export type GitHubAuthor = z.infer; export type GitHubRepository = z.infer; export type GitHubFileContent = z.infer; -export type GitHubDirectoryContent = z.infer; +export type GitHubDirectoryContent = z.infer< + typeof GitHubDirectoryContentSchema +>; export type GitHubContent = z.infer; export type GitHubTree = z.infer; export type GitHubCommit = z.infer; @@ -256,4 +260,4 @@ export type GitHubMilestone = z.infer; export type GitHubIssue = z.infer; export type GitHubSearchResponse = z.infer; export type GitHubPullRequest = z.infer; -export type GitHubPullRequestRef = z.infer; \ No newline at end of file +export type GitHubPullRequestRef = z.infer; diff --git a/project-management/reference/mcp-server/src/github/common/utils.ts b/project-management/reference/mcp-server/src/github/common/utils.ts index e85691a..da534a2 100644 --- a/project-management/reference/mcp-server/src/github/common/utils.ts +++ b/project-management/reference/mcp-server/src/github/common/utils.ts @@ -1,22 +1,25 @@ -import { getUserAgent } from "universal-user-agent"; -import { createGitHubError } from "./errors.js"; -import { VERSION } from "./version.js"; +import { getUserAgent } from 'universal-user-agent'; +import { createGitHubError } from './errors.js'; +import { VERSION } from './version.js'; type RequestOptions = { method?: string; body?: unknown; headers?: Record; -} +}; async function parseResponseBody(response: Response): Promise { - const contentType = response.headers.get("content-type"); - if (contentType?.includes("application/json")) { + const contentType = response.headers.get('content-type'); + if (contentType?.includes('application/json')) { return response.json(); } return response.text(); } -export function buildUrl(baseUrl: string, params: Record): string { +export function buildUrl( + baseUrl: string, + params: Record, +): string { const url = new URL(baseUrl); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { @@ -30,21 +33,22 @@ const USER_AGENT = `modelcontextprotocol/servers/github/v${VERSION} ${getUserAge export async function githubRequest( url: string, - options: RequestOptions = {} + options: RequestOptions = {}, ): Promise { const headers: Record = { - "Accept": "application/vnd.github.v3+json", - "Content-Type": "application/json", - "User-Agent": USER_AGENT, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + 'User-Agent': USER_AGENT, ...options.headers, }; if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { - headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`; + headers['Authorization'] = + `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`; } const response = await fetch(url, { - method: options.method || "GET", + method: options.method || 'GET', headers, body: options.body ? JSON.stringify(options.body) : undefined, }); @@ -61,18 +65,18 @@ export async function githubRequest( export function validateBranchName(branch: string): string { const sanitized = branch.trim(); if (!sanitized) { - throw new Error("Branch name cannot be empty"); + throw new Error('Branch name cannot be empty'); } - if (sanitized.includes("..")) { + if (sanitized.includes('..')) { throw new Error("Branch name cannot contain '..'"); } if (/[\s~^:?*[\\\]]/.test(sanitized)) { - throw new Error("Branch name contains invalid characters"); + throw new Error('Branch name contains invalid characters'); } - if (sanitized.startsWith("/") || sanitized.endsWith("/")) { + if (sanitized.startsWith('/') || sanitized.endsWith('/')) { throw new Error("Branch name cannot start or end with '/'"); } - if (sanitized.endsWith(".lock")) { + if (sanitized.endsWith('.lock')) { throw new Error("Branch name cannot end with '.lock'"); } return sanitized; @@ -81,15 +85,15 @@ export function validateBranchName(branch: string): string { export function validateRepositoryName(name: string): string { const sanitized = name.trim().toLowerCase(); if (!sanitized) { - throw new Error("Repository name cannot be empty"); + throw new Error('Repository name cannot be empty'); } if (!/^[a-z0-9_.-]+$/.test(sanitized)) { throw new Error( - "Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores" + 'Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores', ); } - if (sanitized.startsWith(".") || sanitized.endsWith(".")) { - throw new Error("Repository name cannot start or end with a period"); + if (sanitized.startsWith('.') || sanitized.endsWith('.')) { + throw new Error('Repository name cannot start or end with a period'); } return sanitized; } @@ -97,11 +101,11 @@ export function validateRepositoryName(name: string): string { export function validateOwnerName(owner: string): string { const sanitized = owner.trim().toLowerCase(); if (!sanitized) { - throw new Error("Owner name cannot be empty"); + throw new Error('Owner name cannot be empty'); } if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) { throw new Error( - "Owner name must start with a letter or number and can contain up to 39 characters" + 'Owner name must start with a letter or number and can contain up to 39 characters', ); } return sanitized; @@ -110,15 +114,20 @@ export function validateOwnerName(owner: string): string { export async function checkBranchExists( owner: string, repo: string, - branch: string + branch: string, ): Promise { try { await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/branches/${branch}` + `https://api.github.com/repos/${owner}/${repo}/branches/${branch}`, ); return true; } catch (error) { - if (error && typeof error === "object" && "status" in error && error.status === 404) { + if ( + error && + typeof error === 'object' && + 'status' in error && + error.status === 404 + ) { return false; } throw error; @@ -130,9 +139,14 @@ export async function checkUserExists(username: string): Promise { await githubRequest(`https://api.github.com/users/${username}`); return true; } catch (error) { - if (error && typeof error === "object" && "status" in error && error.status === 404) { + if ( + error && + typeof error === 'object' && + 'status' in error && + error.status === 404 + ) { return false; } throw error; } -} \ No newline at end of file +} diff --git a/project-management/reference/mcp-server/src/github/common/version.ts b/project-management/reference/mcp-server/src/github/common/version.ts index 648f7c6..71f169e 100644 --- a/project-management/reference/mcp-server/src/github/common/version.ts +++ b/project-management/reference/mcp-server/src/github/common/version.ts @@ -1,3 +1,3 @@ // If the format of this file changes, so it doesn't simply export a VERSION constant, // this will break .github/workflows/version-check.yml. -export const VERSION = "0.6.2"; \ No newline at end of file +export const VERSION = '0.6.2'; diff --git a/project-management/reference/mcp-server/src/github/index.ts b/project-management/reference/mcp-server/src/github/index.ts index 88b2368..0cb598b 100644 --- a/project-management/reference/mcp-server/src/github/index.ts +++ b/project-management/reference/mcp-server/src/github/index.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; @@ -25,23 +25,23 @@ import { GitHubConflictError, isGitHubError, } from './common/errors.js'; -import { VERSION } from "./common/version.js"; +import { VERSION } from './common/version.js'; const server = new Server( { - name: "github-mcp-server", + name: 'github-mcp-server', version: VERSION, }, { capabilities: { tools: {}, }, - } + }, ); function formatGitHubError(error: GitHubError): string { let message = `GitHub API Error: ${error.message}`; - + if (error instanceof GitHubValidationError) { message = `Validation Error: ${error.message}`; if (error.response) { @@ -66,90 +66,95 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { - name: "create_or_update_file", - description: "Create or update a single file in a GitHub repository", + name: 'create_or_update_file', + description: 'Create or update a single file in a GitHub repository', inputSchema: zodToJsonSchema(files.CreateOrUpdateFileSchema), }, { - name: "search_repositories", - description: "Search for GitHub repositories", + name: 'search_repositories', + description: 'Search for GitHub repositories', inputSchema: zodToJsonSchema(repository.SearchRepositoriesSchema), }, { - name: "create_repository", - description: "Create a new GitHub repository in your account", + name: 'create_repository', + description: 'Create a new GitHub repository in your account', inputSchema: zodToJsonSchema(repository.CreateRepositoryOptionsSchema), }, { - name: "get_file_contents", - description: "Get the contents of a file or directory from a GitHub repository", + name: 'get_file_contents', + description: + 'Get the contents of a file or directory from a GitHub repository', inputSchema: zodToJsonSchema(files.GetFileContentsSchema), }, { - name: "push_files", - description: "Push multiple files to a GitHub repository in a single commit", + name: 'push_files', + description: + 'Push multiple files to a GitHub repository in a single commit', inputSchema: zodToJsonSchema(files.PushFilesSchema), }, { - name: "create_issue", - description: "Create a new issue in a GitHub repository", + name: 'create_issue', + description: 'Create a new issue in a GitHub repository', inputSchema: zodToJsonSchema(issues.CreateIssueSchema), }, { - name: "create_pull_request", - description: "Create a new pull request in a GitHub repository", + name: 'create_pull_request', + description: 'Create a new pull request in a GitHub repository', inputSchema: zodToJsonSchema(pulls.CreatePullRequestSchema), }, { - name: "fork_repository", - description: "Fork a GitHub repository to your account or specified organization", + name: 'fork_repository', + description: + 'Fork a GitHub repository to your account or specified organization', inputSchema: zodToJsonSchema(repository.ForkRepositorySchema), }, { - name: "create_branch", - description: "Create a new branch in a GitHub repository", + name: 'create_branch', + description: 'Create a new branch in a GitHub repository', inputSchema: zodToJsonSchema(branches.CreateBranchSchema), }, { - name: "list_commits", - description: "Get list of commits of a branch in a GitHub repository", - inputSchema: zodToJsonSchema(commits.ListCommitsSchema) + name: 'list_commits', + description: 'Get list of commits of a branch in a GitHub repository', + inputSchema: zodToJsonSchema(commits.ListCommitsSchema), }, { - name: "list_issues", - description: "List issues in a GitHub repository with filtering options", - inputSchema: zodToJsonSchema(issues.ListIssuesOptionsSchema) + name: 'list_issues', + description: + 'List issues in a GitHub repository with filtering options', + inputSchema: zodToJsonSchema(issues.ListIssuesOptionsSchema), }, { - name: "update_issue", - description: "Update an existing issue in a GitHub repository", - inputSchema: zodToJsonSchema(issues.UpdateIssueOptionsSchema) + name: 'update_issue', + description: 'Update an existing issue in a GitHub repository', + inputSchema: zodToJsonSchema(issues.UpdateIssueOptionsSchema), }, { - name: "add_issue_comment", - description: "Add a comment to an existing issue", - inputSchema: zodToJsonSchema(issues.IssueCommentSchema) + name: 'add_issue_comment', + description: 'Add a comment to an existing issue', + inputSchema: zodToJsonSchema(issues.IssueCommentSchema), }, { - name: "search_code", - description: "Search for code across GitHub repositories", + name: 'search_code', + description: 'Search for code across GitHub repositories', inputSchema: zodToJsonSchema(search.SearchCodeSchema), }, { - name: "search_issues", - description: "Search for issues and pull requests across GitHub repositories", + name: 'search_issues', + description: + 'Search for issues and pull requests across GitHub repositories', inputSchema: zodToJsonSchema(search.SearchIssuesSchema), }, { - name: "search_users", - description: "Search for users on GitHub", + name: 'search_users', + description: 'Search for users on GitHub', inputSchema: zodToJsonSchema(search.SearchUsersSchema), }, { - name: "get_issue", - description: "Get details of a specific issue in a GitHub repository.", - inputSchema: zodToJsonSchema(issues.GetIssueSchema) - } + name: 'get_issue', + description: 'Get details of a specific issue in a GitHub repository.', + inputSchema: zodToJsonSchema(issues.GetIssueSchema), + }, ], }; }); @@ -157,66 +162,82 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments) { - throw new Error("Arguments are required"); + throw new Error('Arguments are required'); } switch (request.params.name) { - case "fork_repository": { - const args = repository.ForkRepositorySchema.parse(request.params.arguments); - const fork = await repository.forkRepository(args.owner, args.repo, args.organization); + case 'fork_repository': { + const args = repository.ForkRepositorySchema.parse( + request.params.arguments, + ); + const fork = await repository.forkRepository( + args.owner, + args.repo, + args.organization, + ); return { - content: [{ type: "text", text: JSON.stringify(fork, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(fork, null, 2) }], }; } - case "create_branch": { - const args = branches.CreateBranchSchema.parse(request.params.arguments); + case 'create_branch': { + const args = branches.CreateBranchSchema.parse( + request.params.arguments, + ); const branch = await branches.createBranchFromRef( args.owner, args.repo, args.branch, - args.from_branch + args.from_branch, ); return { - content: [{ type: "text", text: JSON.stringify(branch, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(branch, null, 2) }], }; } - case "search_repositories": { - const args = repository.SearchRepositoriesSchema.parse(request.params.arguments); + case 'search_repositories': { + const args = repository.SearchRepositoriesSchema.parse( + request.params.arguments, + ); const results = await repository.searchRepositories( args.query, args.page, - args.perPage + args.perPage, ); return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } - case "create_repository": { - const args = repository.CreateRepositoryOptionsSchema.parse(request.params.arguments); + case 'create_repository': { + const args = repository.CreateRepositoryOptionsSchema.parse( + request.params.arguments, + ); const result = await repository.createRepository(args); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "get_file_contents": { - const args = files.GetFileContentsSchema.parse(request.params.arguments); + case 'get_file_contents': { + const args = files.GetFileContentsSchema.parse( + request.params.arguments, + ); const contents = await files.getFileContents( args.owner, args.repo, args.path, - args.branch + args.branch, ); return { - content: [{ type: "text", text: JSON.stringify(contents, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(contents, null, 2) }], }; } - case "create_or_update_file": { - const args = files.CreateOrUpdateFileSchema.parse(request.params.arguments); + case 'create_or_update_file': { + const args = files.CreateOrUpdateFileSchema.parse( + request.params.arguments, + ); const result = await files.createOrUpdateFile( args.owner, args.repo, @@ -224,114 +245,136 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { args.content, args.message, args.branch, - args.sha + args.sha, ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "push_files": { + case 'push_files': { const args = files.PushFilesSchema.parse(request.params.arguments); const result = await files.pushFiles( args.owner, args.repo, args.branch, args.files, - args.message + args.message, ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "create_issue": { + case 'create_issue': { const args = issues.CreateIssueSchema.parse(request.params.arguments); const { owner, repo, ...options } = args; const issue = await issues.createIssue(owner, repo, options); return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }], }; } - case "create_pull_request": { - const args = pulls.CreatePullRequestSchema.parse(request.params.arguments); + case 'create_pull_request': { + const args = pulls.CreatePullRequestSchema.parse( + request.params.arguments, + ); const pullRequest = await pulls.createPullRequest(args); return { - content: [{ type: "text", text: JSON.stringify(pullRequest, null, 2) }], + content: [ + { type: 'text', text: JSON.stringify(pullRequest, null, 2) }, + ], }; } - case "search_code": { + case 'search_code': { const args = search.SearchCodeSchema.parse(request.params.arguments); const results = await search.searchCode(args); return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } - case "search_issues": { + case 'search_issues': { const args = search.SearchIssuesSchema.parse(request.params.arguments); const results = await search.searchIssues(args); return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } - case "search_users": { + case 'search_users': { const args = search.SearchUsersSchema.parse(request.params.arguments); const results = await search.searchUsers(args); return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } - case "list_issues": { - const args = issues.ListIssuesOptionsSchema.parse(request.params.arguments); + case 'list_issues': { + const args = issues.ListIssuesOptionsSchema.parse( + request.params.arguments, + ); const { owner, repo, ...options } = args; const result = await issues.listIssues(owner, repo, options); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "update_issue": { - const args = issues.UpdateIssueOptionsSchema.parse(request.params.arguments); + case 'update_issue': { + const args = issues.UpdateIssueOptionsSchema.parse( + request.params.arguments, + ); const { owner, repo, issue_number, ...options } = args; - const result = await issues.updateIssue(owner, repo, issue_number, options); + const result = await issues.updateIssue( + owner, + repo, + issue_number, + options, + ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "add_issue_comment": { + case 'add_issue_comment': { const args = issues.IssueCommentSchema.parse(request.params.arguments); const { owner, repo, issue_number, body } = args; - const result = await issues.addIssueComment(owner, repo, issue_number, body); + const result = await issues.addIssueComment( + owner, + repo, + issue_number, + body, + ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - case "list_commits": { + case 'list_commits': { const args = commits.ListCommitsSchema.parse(request.params.arguments); const results = await commits.listCommits( args.owner, args.repo, args.page, args.perPage, - args.sha + args.sha, ); return { - content: [{ type: "text", text: JSON.stringify(results, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], }; } - case "get_issue": { + case 'get_issue': { const args = issues.GetIssueSchema.parse(request.params.arguments); - const issue = await issues.getIssue(args.owner, args.repo, args.issue_number); + const issue = await issues.getIssue( + args.owner, + args.repo, + args.issue_number, + ); return { - content: [{ type: "text", text: JSON.stringify(issue, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(issue, null, 2) }], }; } @@ -352,10 +395,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); - console.error("GitHub MCP Server running on stdio"); + console.error('GitHub MCP Server running on stdio'); } runServer().catch((error) => { - console.error("Fatal error in main():", error); + console.error('Fatal error in main():', error); process.exit(1); -}); \ No newline at end of file +}); diff --git a/project-management/reference/mcp-server/src/github/operations/branches.ts b/project-management/reference/mcp-server/src/github/operations/branches.ts index 9b7033b..d772d90 100644 --- a/project-management/reference/mcp-server/src/github/operations/branches.ts +++ b/project-management/reference/mcp-server/src/github/operations/branches.ts @@ -1,6 +1,6 @@ -import { z } from "zod"; -import { githubRequest } from "../common/utils.js"; -import { GitHubReferenceSchema } from "../common/types.js"; +import { z } from 'zod'; +import { githubRequest } from '../common/utils.js'; +import { GitHubReferenceSchema } from '../common/types.js'; // Schema definitions export const CreateBranchOptionsSchema = z.object({ @@ -9,29 +9,39 @@ export const CreateBranchOptionsSchema = z.object({ }); export const CreateBranchSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - branch: z.string().describe("Name for the new branch"), - from_branch: z.string().optional().describe("Optional: source branch to create from (defaults to the repository's default branch)"), + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + branch: z.string().describe('Name for the new branch'), + from_branch: z + .string() + .optional() + .describe( + "Optional: source branch to create from (defaults to the repository's default branch)", + ), }); // Type exports export type CreateBranchOptions = z.infer; // Function implementations -export async function getDefaultBranchSHA(owner: string, repo: string): Promise { +export async function getDefaultBranchSHA( + owner: string, + repo: string, +): Promise { try { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main` + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/main`, ); const data = GitHubReferenceSchema.parse(response); return data.object.sha; } catch (error) { const masterResponse = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master` + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/master`, ); if (!masterResponse) { - throw new Error("Could not find default branch (tried 'main' and 'master')"); + throw new Error( + "Could not find default branch (tried 'main' and 'master')", + ); } const data = GitHubReferenceSchema.parse(masterResponse); return data.object.sha; @@ -41,19 +51,19 @@ export async function getDefaultBranchSHA(owner: string, repo: string): Promise< export async function createBranch( owner: string, repo: string, - options: CreateBranchOptions + options: CreateBranchOptions, ): Promise> { const fullRef = `refs/heads/${options.ref}`; const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/git/refs`, { - method: "POST", + method: 'POST', body: { ref: fullRef, sha: options.sha, }, - } + }, ); return GitHubReferenceSchema.parse(response); @@ -62,10 +72,10 @@ export async function createBranch( export async function getBranchSHA( owner: string, repo: string, - branch: string + branch: string, ): Promise { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}` + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, ); const data = GitHubReferenceSchema.parse(response); @@ -76,7 +86,7 @@ export async function createBranchFromRef( owner: string, repo: string, newBranch: string, - fromBranch?: string + fromBranch?: string, ): Promise> { let sha: string; if (fromBranch) { @@ -95,17 +105,17 @@ export async function updateBranch( owner: string, repo: string, branch: string, - sha: string + sha: string, ): Promise> { const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, { - method: "PATCH", + method: 'PATCH', body: { sha, force: true, }, - } + }, ); return GitHubReferenceSchema.parse(response); diff --git a/project-management/reference/mcp-server/src/github/operations/commits.ts b/project-management/reference/mcp-server/src/github/operations/commits.ts index b10e1b5..14db7b3 100644 --- a/project-management/reference/mcp-server/src/github/operations/commits.ts +++ b/project-management/reference/mcp-server/src/github/operations/commits.ts @@ -1,12 +1,12 @@ -import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils.js"; +import { z } from 'zod'; +import { githubRequest, buildUrl } from '../common/utils.js'; export const ListCommitsSchema = z.object({ owner: z.string(), repo: z.string(), sha: z.string().optional(), page: z.number().optional(), - perPage: z.number().optional() + perPage: z.number().optional(), }); export async function listCommits( @@ -14,13 +14,13 @@ export async function listCommits( repo: string, page?: number, perPage?: number, - sha?: string + sha?: string, ) { return githubRequest( buildUrl(`https://api.github.com/repos/${owner}/${repo}/commits`, { page: page?.toString(), per_page: perPage?.toString(), - sha - }) + sha, + }), ); -} \ No newline at end of file +} diff --git a/project-management/reference/mcp-server/src/github/operations/files.ts b/project-management/reference/mcp-server/src/github/operations/files.ts index 9517946..c56c2f0 100644 --- a/project-management/reference/mcp-server/src/github/operations/files.ts +++ b/project-management/reference/mcp-server/src/github/operations/files.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { githubRequest } from "../common/utils.js"; +import { z } from 'zod'; +import { githubRequest } from '../common/utils.js'; import { GitHubContentSchema, GitHubAuthorSchema, @@ -7,7 +7,7 @@ import { GitHubCommitSchema, GitHubReferenceSchema, GitHubFileContentSchema, -} from "../common/types.js"; +} from '../common/types.js'; // Schema definitions export const FileOperationSchema = z.object({ @@ -16,28 +16,33 @@ export const FileOperationSchema = z.object({ }); export const CreateOrUpdateFileSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - path: z.string().describe("Path where to create/update the file"), - content: z.string().describe("Content of the file"), - message: z.string().describe("Commit message"), - branch: z.string().describe("Branch to create/update the file in"), - sha: z.string().optional().describe("SHA of the file being replaced (required when updating existing files)"), + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + path: z.string().describe('Path where to create/update the file'), + content: z.string().describe('Content of the file'), + message: z.string().describe('Commit message'), + branch: z.string().describe('Branch to create/update the file in'), + sha: z + .string() + .optional() + .describe( + 'SHA of the file being replaced (required when updating existing files)', + ), }); export const GetFileContentsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - path: z.string().describe("Path to the file or directory"), - branch: z.string().optional().describe("Branch to get contents from"), + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + path: z.string().describe('Path to the file or directory'), + branch: z.string().optional().describe('Branch to get contents from'), }); export const PushFilesSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), branch: z.string().describe("Branch to push to (e.g., 'main' or 'master')"), - files: z.array(FileOperationSchema).describe("Array of files to push"), - message: z.string().describe("Commit message"), + files: z.array(FileOperationSchema).describe('Array of files to push'), + message: z.string().describe('Commit message'), }); export const GitHubCreateUpdateFileResponseSchema = z.object({ @@ -59,21 +64,23 @@ export const GitHubCreateUpdateFileResponseSchema = z.object({ sha: z.string(), url: z.string(), html_url: z.string(), - }) + }), ), }), }); // Type exports export type FileOperation = z.infer; -export type GitHubCreateUpdateFileResponse = z.infer; +export type GitHubCreateUpdateFileResponse = z.infer< + typeof GitHubCreateUpdateFileResponseSchema +>; // Function implementations export async function getFileContents( owner: string, repo: string, path: string, - branch?: string + branch?: string, ) { let url = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`; if (branch) { @@ -85,7 +92,7 @@ export async function getFileContents( // If it's a file, decode the content if (!Array.isArray(data) && data.content) { - data.content = Buffer.from(data.content, "base64").toString("utf8"); + data.content = Buffer.from(data.content, 'base64').toString('utf8'); } return data; @@ -98,9 +105,9 @@ export async function createOrUpdateFile( content: string, message: string, branch: string, - sha?: string + sha?: string, ) { - const encodedContent = Buffer.from(content).toString("base64"); + const encodedContent = Buffer.from(content).toString('base64'); let currentSha = sha; if (!currentSha) { @@ -110,7 +117,9 @@ export async function createOrUpdateFile( currentSha = existingFile.sha; } } catch (error) { - console.error("Note: File does not exist in branch, will create new file"); + console.error( + 'Note: File does not exist in branch, will create new file', + ); } } @@ -123,7 +132,7 @@ export async function createOrUpdateFile( }; const response = await githubRequest(url, { - method: "PUT", + method: 'PUT', body, }); @@ -134,24 +143,24 @@ async function createTree( owner: string, repo: string, files: FileOperation[], - baseTree?: string + baseTree?: string, ) { const tree = files.map((file) => ({ path: file.path, - mode: "100644" as const, - type: "blob" as const, + mode: '100644' as const, + type: 'blob' as const, content: file.content, })); const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/git/trees`, { - method: "POST", + method: 'POST', body: { tree, base_tree: baseTree, }, - } + }, ); return GitHubTreeSchema.parse(response); @@ -162,18 +171,18 @@ async function createCommit( repo: string, message: string, tree: string, - parents: string[] + parents: string[], ) { const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/git/commits`, { - method: "POST", + method: 'POST', body: { message, tree, parents, }, - } + }, ); return GitHubCommitSchema.parse(response); @@ -183,17 +192,17 @@ async function updateReference( owner: string, repo: string, ref: string, - sha: string + sha: string, ) { const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/git/refs/${ref}`, { - method: "PATCH", + method: 'PATCH', body: { sha, force: true, }, - } + }, ); return GitHubReferenceSchema.parse(response); @@ -204,16 +213,18 @@ export async function pushFiles( repo: string, branch: string, files: FileOperation[], - message: string + message: string, ) { const refResponse = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}` + `https://api.github.com/repos/${owner}/${repo}/git/refs/heads/${branch}`, ); const ref = GitHubReferenceSchema.parse(refResponse); const commitSha = ref.object.sha; const tree = await createTree(owner, repo, files, commitSha); - const commit = await createCommit(owner, repo, message, tree.sha, [commitSha]); + const commit = await createCommit(owner, repo, message, tree.sha, [ + commitSha, + ]); return await updateReference(owner, repo, `heads/${branch}`, commit.sha); } diff --git a/project-management/reference/mcp-server/src/github/operations/issues.ts b/project-management/reference/mcp-server/src/github/operations/issues.ts index d2907bf..d7e3980 100644 --- a/project-management/reference/mcp-server/src/github/operations/issues.ts +++ b/project-management/reference/mcp-server/src/github/operations/issues.ts @@ -1,5 +1,5 @@ -import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils.js"; +import { z } from 'zod'; +import { githubRequest, buildUrl } from '../common/utils.js'; export const GetIssueSchema = z.object({ owner: z.string(), @@ -31,13 +31,13 @@ export const CreateIssueSchema = z.object({ export const ListIssuesOptionsSchema = z.object({ owner: z.string(), repo: z.string(), - direction: z.enum(["asc", "desc"]).optional(), + direction: z.enum(['asc', 'desc']).optional(), labels: z.array(z.string()).optional(), page: z.number().optional(), per_page: z.number().optional(), since: z.string().optional(), - sort: z.enum(["created", "updated", "comments"]).optional(), - state: z.enum(["open", "closed", "all"]).optional(), + sort: z.enum(['created', 'updated', 'comments']).optional(), + state: z.enum(['open', 'closed', 'all']).optional(), }); export const UpdateIssueOptionsSchema = z.object({ @@ -49,56 +49,62 @@ export const UpdateIssueOptionsSchema = z.object({ assignees: z.array(z.string()).optional(), milestone: z.number().optional(), labels: z.array(z.string()).optional(), - state: z.enum(["open", "closed"]).optional(), + state: z.enum(['open', 'closed']).optional(), }); -export async function getIssue(owner: string, repo: string, issue_number: number) { - return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`); +export async function getIssue( + owner: string, + repo: string, + issue_number: number, +) { + return githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`, + ); } export async function addIssueComment( owner: string, repo: string, issue_number: number, - body: string + body: string, ) { - return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, { - method: "POST", - body: { body }, - }); + return githubRequest( + `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}/comments`, + { + method: 'POST', + body: { body }, + }, + ); } export async function createIssue( owner: string, repo: string, - options: z.infer + options: z.infer, ) { - return githubRequest( - `https://api.github.com/repos/${owner}/${repo}/issues`, - { - method: "POST", - body: options, - } - ); + return githubRequest(`https://api.github.com/repos/${owner}/${repo}/issues`, { + method: 'POST', + body: options, + }); } export async function listIssues( owner: string, repo: string, - options: Omit, "owner" | "repo"> + options: Omit, 'owner' | 'repo'>, ) { const urlParams: Record = { direction: options.direction, - labels: options.labels?.join(","), + labels: options.labels?.join(','), page: options.page?.toString(), per_page: options.per_page?.toString(), since: options.since, sort: options.sort, - state: options.state + state: options.state, }; return githubRequest( - buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, urlParams) + buildUrl(`https://api.github.com/repos/${owner}/${repo}/issues`, urlParams), ); } @@ -106,13 +112,16 @@ export async function updateIssue( owner: string, repo: string, issue_number: number, - options: Omit, "owner" | "repo" | "issue_number"> + options: Omit< + z.infer, + 'owner' | 'repo' | 'issue_number' + >, ) { return githubRequest( `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`, { - method: "PATCH", + method: 'PATCH', body: options, - } + }, ); -} \ No newline at end of file +} diff --git a/project-management/reference/mcp-server/src/github/operations/pulls.ts b/project-management/reference/mcp-server/src/github/operations/pulls.ts index 9b1a5bd..c897afb 100644 --- a/project-management/reference/mcp-server/src/github/operations/pulls.ts +++ b/project-management/reference/mcp-server/src/github/operations/pulls.ts @@ -1,23 +1,31 @@ -import { z } from "zod"; -import { githubRequest } from "../common/utils.js"; +import { z } from 'zod'; +import { githubRequest } from '../common/utils.js'; import { GitHubPullRequestSchema, GitHubIssueAssigneeSchema, GitHubRepositorySchema, -} from "../common/types.js"; +} from '../common/types.js'; // Schema definitions export const PullRequestFileSchema = z.object({ sha: z.string(), filename: z.string(), - status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + status: z.enum([ + 'added', + 'removed', + 'modified', + 'renamed', + 'copied', + 'changed', + 'unchanged', + ]), additions: z.number(), deletions: z.number(), changes: z.number(), blob_url: z.string(), raw_url: z.string(), contents_url: z.string(), - patch: z.string().optional() + patch: z.string().optional(), }); export const StatusCheckSchema = z.object({ @@ -27,14 +35,14 @@ export const StatusCheckSchema = z.object({ target_url: z.string().nullable(), context: z.string(), created_at: z.string(), - updated_at: z.string() + updated_at: z.string(), }); export const CombinedStatusSchema = z.object({ state: z.enum(['error', 'failure', 'pending', 'success']), statuses: z.array(StatusCheckSchema), sha: z.string(), - total_count: z.number() + total_count: z.number(), }); export const PullRequestCommentSchema = z.object({ @@ -58,8 +66,8 @@ export const PullRequestCommentSchema = z.object({ _links: z.object({ self: z.object({ href: z.string() }), html: z.object({ href: z.string() }), - pull_request: z.object({ href: z.string() }) - }) + pull_request: z.object({ href: z.string() }), + }), }); export const PullRequestReviewSchema = z.object({ @@ -67,110 +75,166 @@ export const PullRequestReviewSchema = z.object({ node_id: z.string(), user: GitHubIssueAssigneeSchema, body: z.string().nullable(), - state: z.enum(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']), + state: z.enum([ + 'APPROVED', + 'CHANGES_REQUESTED', + 'COMMENTED', + 'DISMISSED', + 'PENDING', + ]), html_url: z.string(), pull_request_url: z.string(), commit_id: z.string(), submitted_at: z.string().nullable(), - author_association: z.string() + author_association: z.string(), }); // Input schemas export const CreatePullRequestSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - title: z.string().describe("Pull request title"), - body: z.string().optional().describe("Pull request body/description"), - head: z.string().describe("The name of the branch where your changes are implemented"), - base: z.string().describe("The name of the branch you want the changes pulled into"), - draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), - maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + title: z.string().describe('Pull request title'), + body: z.string().optional().describe('Pull request body/description'), + head: z + .string() + .describe('The name of the branch where your changes are implemented'), + base: z + .string() + .describe('The name of the branch you want the changes pulled into'), + draft: z + .boolean() + .optional() + .describe('Whether to create the pull request as a draft'), + maintainer_can_modify: z + .boolean() + .optional() + .describe('Whether maintainers can modify the pull request'), }); export const GetPullRequestSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), }); export const ListPullRequestsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - state: z.enum(['open', 'closed', 'all']).optional().describe("State of the pull requests to return"), - head: z.string().optional().describe("Filter by head user or head organization and branch name"), - base: z.string().optional().describe("Filter by base branch name"), - sort: z.enum(['created', 'updated', 'popularity', 'long-running']).optional().describe("What to sort results by"), - direction: z.enum(['asc', 'desc']).optional().describe("The direction of the sort"), - per_page: z.number().optional().describe("Results per page (max 100)"), - page: z.number().optional().describe("Page number of the results") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + state: z + .enum(['open', 'closed', 'all']) + .optional() + .describe('State of the pull requests to return'), + head: z + .string() + .optional() + .describe('Filter by head user or head organization and branch name'), + base: z.string().optional().describe('Filter by base branch name'), + sort: z + .enum(['created', 'updated', 'popularity', 'long-running']) + .optional() + .describe('What to sort results by'), + direction: z + .enum(['asc', 'desc']) + .optional() + .describe('The direction of the sort'), + per_page: z.number().optional().describe('Results per page (max 100)'), + page: z.number().optional().describe('Page number of the results'), }); export const CreatePullRequestReviewSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number"), - commit_id: z.string().optional().describe("The SHA of the commit that needs a review"), - body: z.string().describe("The body text of the review"), - event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe("The review action to perform"), - comments: z.array(z.object({ - path: z.string().describe("The relative path to the file being commented on"), - position: z.number().describe("The position in the diff where you want to add a review comment"), - body: z.string().describe("Text of the review comment") - })).optional().describe("Comments to post as part of the review") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), + commit_id: z + .string() + .optional() + .describe('The SHA of the commit that needs a review'), + body: z.string().describe('The body text of the review'), + event: z + .enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']) + .describe('The review action to perform'), + comments: z + .array( + z.object({ + path: z + .string() + .describe('The relative path to the file being commented on'), + position: z + .number() + .describe( + 'The position in the diff where you want to add a review comment', + ), + body: z.string().describe('Text of the review comment'), + }), + ) + .optional() + .describe('Comments to post as part of the review'), }); export const MergePullRequestSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number"), - commit_title: z.string().optional().describe("Title for the automatic commit message"), - commit_message: z.string().optional().describe("Extra detail to append to automatic commit message"), - merge_method: z.enum(['merge', 'squash', 'rebase']).optional().describe("Merge method to use") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), + commit_title: z + .string() + .optional() + .describe('Title for the automatic commit message'), + commit_message: z + .string() + .optional() + .describe('Extra detail to append to automatic commit message'), + merge_method: z + .enum(['merge', 'squash', 'rebase']) + .optional() + .describe('Merge method to use'), }); export const GetPullRequestFilesSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), }); export const GetPullRequestStatusSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), }); export const UpdatePullRequestBranchSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number"), - expected_head_sha: z.string().optional().describe("The expected SHA of the pull request's HEAD ref") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), + expected_head_sha: z + .string() + .optional() + .describe("The expected SHA of the pull request's HEAD ref"), }); export const GetPullRequestCommentsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), }); export const GetPullRequestReviewsSchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - pull_number: z.number().describe("Pull request number") + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + pull_number: z.number().describe('Pull request number'), }); // Function implementations export async function createPullRequest( - params: z.infer + params: z.infer, ): Promise> { const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls`, { - method: "POST", + method: 'POST', body: options, - } + }, ); return GitHubPullRequestSchema.parse(response); @@ -179,10 +243,10 @@ export async function createPullRequest( export async function getPullRequest( owner: string, repo: string, - pullNumber: number + pullNumber: number, ): Promise> { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`, ); return GitHubPullRequestSchema.parse(response); } @@ -190,16 +254,18 @@ export async function getPullRequest( export async function listPullRequests( owner: string, repo: string, - options: Omit, 'owner' | 'repo'> + options: Omit, 'owner' | 'repo'>, ): Promise[]> { const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); - + if (options.state) url.searchParams.append('state', options.state); if (options.head) url.searchParams.append('head', options.head); if (options.base) url.searchParams.append('base', options.base); if (options.sort) url.searchParams.append('sort', options.sort); - if (options.direction) url.searchParams.append('direction', options.direction); - if (options.per_page) url.searchParams.append('per_page', options.per_page.toString()); + if (options.direction) + url.searchParams.append('direction', options.direction); + if (options.per_page) + url.searchParams.append('per_page', options.per_page.toString()); if (options.page) url.searchParams.append('page', options.page.toString()); const response = await githubRequest(url.toString()); @@ -210,14 +276,17 @@ export async function createPullRequestReview( owner: string, repo: string, pullNumber: number, - options: Omit, 'owner' | 'repo' | 'pull_number'> + options: Omit< + z.infer, + 'owner' | 'repo' | 'pull_number' + >, ): Promise> { const response = await githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, { method: 'POST', body: options, - } + }, ); return PullRequestReviewSchema.parse(response); } @@ -226,24 +295,27 @@ export async function mergePullRequest( owner: string, repo: string, pullNumber: number, - options: Omit, 'owner' | 'repo' | 'pull_number'> + options: Omit< + z.infer, + 'owner' | 'repo' | 'pull_number' + >, ): Promise { return githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/merge`, { method: 'PUT', body: options, - } + }, ); } export async function getPullRequestFiles( owner: string, repo: string, - pullNumber: number + pullNumber: number, ): Promise[]> { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files` + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files`, ); return z.array(PullRequestFileSchema).parse(response); } @@ -252,24 +324,26 @@ export async function updatePullRequestBranch( owner: string, repo: string, pullNumber: number, - expectedHeadSha?: string + expectedHeadSha?: string, ): Promise { await githubRequest( `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/update-branch`, { - method: "PUT", - body: expectedHeadSha ? { expected_head_sha: expectedHeadSha } : undefined, - } + method: 'PUT', + body: expectedHeadSha + ? { expected_head_sha: expectedHeadSha } + : undefined, + }, ); } export async function getPullRequestComments( owner: string, repo: string, - pullNumber: number + pullNumber: number, ): Promise[]> { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/comments` + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/comments`, ); return z.array(PullRequestCommentSchema).parse(response); } @@ -277,10 +351,10 @@ export async function getPullRequestComments( export async function getPullRequestReviews( owner: string, repo: string, - pullNumber: number + pullNumber: number, ): Promise[]> { const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews` + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, ); return z.array(PullRequestReviewSchema).parse(response); } @@ -288,7 +362,7 @@ export async function getPullRequestReviews( export async function getPullRequestStatus( owner: string, repo: string, - pullNumber: number + pullNumber: number, ): Promise> { // First get the PR to get the head SHA const pr = await getPullRequest(owner, repo, pullNumber); @@ -296,7 +370,7 @@ export async function getPullRequestStatus( // Then get the combined status for that SHA const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status` + `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status`, ); return CombinedStatusSchema.parse(response); -} \ No newline at end of file +} diff --git a/project-management/reference/mcp-server/src/github/operations/repository.ts b/project-management/reference/mcp-server/src/github/operations/repository.ts index 4cf0ab9..47e0a0d 100644 --- a/project-management/reference/mcp-server/src/github/operations/repository.ts +++ b/project-management/reference/mcp-server/src/github/operations/repository.ts @@ -1,34 +1,53 @@ -import { z } from "zod"; -import { githubRequest } from "../common/utils.js"; -import { GitHubRepositorySchema, GitHubSearchResponseSchema } from "../common/types.js"; +import { z } from 'zod'; +import { githubRequest } from '../common/utils.js'; +import { + GitHubRepositorySchema, + GitHubSearchResponseSchema, +} from '../common/types.js'; // Schema definitions export const CreateRepositoryOptionsSchema = z.object({ - name: z.string().describe("Repository name"), - description: z.string().optional().describe("Repository description"), - private: z.boolean().optional().describe("Whether the repository should be private"), - autoInit: z.boolean().optional().describe("Initialize with README.md"), + name: z.string().describe('Repository name'), + description: z.string().optional().describe('Repository description'), + private: z + .boolean() + .optional() + .describe('Whether the repository should be private'), + autoInit: z.boolean().optional().describe('Initialize with README.md'), }); export const SearchRepositoriesSchema = z.object({ - query: z.string().describe("Search query (see GitHub search syntax)"), - page: z.number().optional().describe("Page number for pagination (default: 1)"), - perPage: z.number().optional().describe("Number of results per page (default: 30, max: 100)"), + query: z.string().describe('Search query (see GitHub search syntax)'), + page: z + .number() + .optional() + .describe('Page number for pagination (default: 1)'), + perPage: z + .number() + .optional() + .describe('Number of results per page (default: 30, max: 100)'), }); export const ForkRepositorySchema = z.object({ - owner: z.string().describe("Repository owner (username or organization)"), - repo: z.string().describe("Repository name"), - organization: z.string().optional().describe("Optional: organization to fork to (defaults to your personal account)"), + owner: z.string().describe('Repository owner (username or organization)'), + repo: z.string().describe('Repository name'), + organization: z + .string() + .optional() + .describe( + 'Optional: organization to fork to (defaults to your personal account)', + ), }); // Type exports -export type CreateRepositoryOptions = z.infer; +export type CreateRepositoryOptions = z.infer< + typeof CreateRepositoryOptionsSchema +>; // Function implementations export async function createRepository(options: CreateRepositoryOptions) { - const response = await githubRequest("https://api.github.com/user/repos", { - method: "POST", + const response = await githubRequest('https://api.github.com/user/repos', { + method: 'POST', body: options, }); return GitHubRepositorySchema.parse(response); @@ -37,12 +56,12 @@ export async function createRepository(options: CreateRepositoryOptions) { export async function searchRepositories( query: string, page: number = 1, - perPage: number = 30 + perPage: number = 30, ) { - const url = new URL("https://api.github.com/search/repositories"); - url.searchParams.append("q", query); - url.searchParams.append("page", page.toString()); - url.searchParams.append("per_page", perPage.toString()); + const url = new URL('https://api.github.com/search/repositories'); + url.searchParams.append('q', query); + url.searchParams.append('page', page.toString()); + url.searchParams.append('per_page', perPage.toString()); const response = await githubRequest(url.toString()); return GitHubSearchResponseSchema.parse(response); @@ -51,13 +70,13 @@ export async function searchRepositories( export async function forkRepository( owner: string, repo: string, - organization?: string + organization?: string, ) { const url = organization ? `https://api.github.com/repos/${owner}/${repo}/forks?organization=${organization}` : `https://api.github.com/repos/${owner}/${repo}/forks`; - const response = await githubRequest(url, { method: "POST" }); + const response = await githubRequest(url, { method: 'POST' }); return GitHubRepositorySchema.extend({ parent: GitHubRepositorySchema, source: GitHubRepositorySchema, diff --git a/project-management/reference/mcp-server/src/github/operations/search.ts b/project-management/reference/mcp-server/src/github/operations/search.ts index 76faa72..8abe397 100644 --- a/project-management/reference/mcp-server/src/github/operations/search.ts +++ b/project-management/reference/mcp-server/src/github/operations/search.ts @@ -1,31 +1,33 @@ -import { z } from "zod"; -import { githubRequest, buildUrl } from "../common/utils.js"; +import { z } from 'zod'; +import { githubRequest, buildUrl } from '../common/utils.js'; export const SearchOptions = z.object({ q: z.string(), - order: z.enum(["asc", "desc"]).optional(), + order: z.enum(['asc', 'desc']).optional(), page: z.number().min(1).optional(), per_page: z.number().min(1).max(100).optional(), }); export const SearchUsersOptions = SearchOptions.extend({ - sort: z.enum(["followers", "repositories", "joined"]).optional(), + sort: z.enum(['followers', 'repositories', 'joined']).optional(), }); export const SearchIssuesOptions = SearchOptions.extend({ - sort: z.enum([ - "comments", - "reactions", - "reactions-+1", - "reactions--1", - "reactions-smile", - "reactions-thinking_face", - "reactions-heart", - "reactions-tada", - "interactions", - "created", - "updated", - ]).optional(), + sort: z + .enum([ + 'comments', + 'reactions', + 'reactions-+1', + 'reactions--1', + 'reactions-smile', + 'reactions-thinking_face', + 'reactions-heart', + 'reactions-tada', + 'interactions', + 'created', + 'updated', + ]) + .optional(), }); export const SearchCodeSchema = SearchOptions; @@ -33,13 +35,15 @@ export const SearchUsersSchema = SearchUsersOptions; export const SearchIssuesSchema = SearchIssuesOptions; export async function searchCode(params: z.infer) { - return githubRequest(buildUrl("https://api.github.com/search/code", params)); + return githubRequest(buildUrl('https://api.github.com/search/code', params)); } export async function searchIssues(params: z.infer) { - return githubRequest(buildUrl("https://api.github.com/search/issues", params)); + return githubRequest( + buildUrl('https://api.github.com/search/issues', params), + ); } export async function searchUsers(params: z.infer) { - return githubRequest(buildUrl("https://api.github.com/search/users", params)); -} \ No newline at end of file + return githubRequest(buildUrl('https://api.github.com/search/users', params)); +} diff --git a/project-management/reference/mcp-server/src/github/tsconfig.json b/project-management/reference/mcp-server/src/github/tsconfig.json index 4d33cae..829d52d 100644 --- a/project-management/reference/mcp-server/src/github/tsconfig.json +++ b/project-management/reference/mcp-server/src/github/tsconfig.json @@ -1,11 +1,8 @@ { - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "." - }, - "include": [ - "./**/*.ts" - ] - } - \ No newline at end of file + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "." + }, + "include": ["./**/*.ts"] +} diff --git a/project-management/research/github-mcp-server-pt1.md b/project-management/research/github-mcp-server-pt1.md index 2d70f1f..0b6c459 100644 --- a/project-management/research/github-mcp-server-pt1.md +++ b/project-management/research/github-mcp-server-pt1.md @@ -1,4 +1,5 @@ ### Key Points + - Research suggests the GitHub reference server in the Model Context Protocol (MCP) enables AI models to interact with GitHub for tasks like file operations and repository management. - It seems likely that the server, requiring a GitHub Personal Access Token, supports 22 tools, including creating issues and pull requests, enhancing AI-assisted coding. - The evidence leans toward its use in applications like Claude Desktop, potentially automating developer workflows securely. @@ -27,48 +28,52 @@ For more details, check the [MCP servers repository](https://github.com/modelcon This note provides an in-depth exploration of the GitHub reference server within the Model Context Protocol (MCP) servers repository, focusing on its purpose, technical details, and broader implications for AI-assisted software development. The analysis is based on available documentation, specifications, and community insights, aiming to offer a thorough understanding for researchers, developers, and AI enthusiasts as of February 27, 2025. #### Introduction to the GitHub Reference Server + The GitHub reference server is part of the [modelcontextprotocol/servers repository](https://github.com/modelcontextprotocol/servers), a collection of reference implementations for MCP, an open protocol developed by Anthropic to facilitate seamless integration between Large Language Models (LLMs) and external data sources and tools. Introduced on November 24, 2024, MCP addresses the challenge of LLMs being constrained by data isolation, and the GitHub server specifically enables AI models to interact with GitHub repositories and APIs, enhancing automation in software development workflows. #### Purpose and Significance + The GitHub server aims to provide a standardized way for AI models to perform GitHub-related tasks, such as file operations, repository management, and issue tracking. This is particularly significant for AI-assisted coding, where LLMs can not only generate code but also manage the GitHub repository, automating tasks like creating pull requests or searching for code. By integrating with MCP, it reduces the need for custom integrations, making it easier for developers to build scalable AI systems that interact with GitHub securely. The server's significance lies in its ability to bridge the gap between AI and version control systems, potentially revolutionizing how developers collaborate and manage code. Early adopters, such as companies using Claude Desktop, have integrated MCP servers, highlighting their potential for enhancing productivity in software development. #### Technical Details and Capabilities + The GitHub server is implemented using either the Typescript MCP SDK ([modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)) or Python MCP SDK ([modelcontextprotocol/python-sdk](https://github.com/modelcontextprotocol/python-sdk)), following MCP's client-server architecture. It exposes 22 tools that correspond to GitHub API functionalities, enabling a wide range of operations. Below is a detailed table of these tools: -| **Tool Name** | **Description** | -|-----------------------------------|---------------------------------------------------------------------------------| -| create_or_update_file | Creates or updates a file in a GitHub repository | -| push_files | Pushes multiple files to a repository, maintaining Git history | -| search_repositories | Searches for GitHub repositories using specified criteria | -| create_repository | Creates a new GitHub repository | -| get_file_contents | Retrieves the contents of a file from a GitHub repository | -| create_issue | Creates a new issue in a GitHub repository | -| create_pull_request | Creates a pull request in a GitHub repository | -| fork_repository | Forks an existing GitHub repository | -| create_branch | Creates a new branch in a GitHub repository | -| list_issues | Lists all issues in a GitHub repository | -| update_issue | Updates an existing issue in a GitHub repository | -| add_issue_comment | Adds a comment to an existing issue | -| search_code | Searches for code within GitHub repositories | -| search_issues | Searches for issues within GitHub repositories | -| search_users | Searches for GitHub users | -| list_commits | Lists commits in a GitHub repository | -| get_issue | Retrieves details of a specific issue | -| get_pull_request | Retrieves details of a specific pull request | -| list_pull_requests | Lists all pull requests in a GitHub repository | -| create_pull_request_review | Creates a review for a pull request | -| merge_pull_request | Merges a pull request in a GitHub repository | -| get_pull_request_files | Retrieves files associated with a pull request | -| get_pull_request_status | Retrieves the status of a pull request | -| update_pull_request_branch | Updates a pull request branch with the latest changes from the base branch | -| get_pull_request_comments | Retrieves comments on a pull request | -| get_pull_request_reviews | Retrieves reviews on a pull request | +| **Tool Name** | **Description** | +| -------------------------- | -------------------------------------------------------------------------- | +| create_or_update_file | Creates or updates a file in a GitHub repository | +| push_files | Pushes multiple files to a repository, maintaining Git history | +| search_repositories | Searches for GitHub repositories using specified criteria | +| create_repository | Creates a new GitHub repository | +| get_file_contents | Retrieves the contents of a file from a GitHub repository | +| create_issue | Creates a new issue in a GitHub repository | +| create_pull_request | Creates a pull request in a GitHub repository | +| fork_repository | Forks an existing GitHub repository | +| create_branch | Creates a new branch in a GitHub repository | +| list_issues | Lists all issues in a GitHub repository | +| update_issue | Updates an existing issue in a GitHub repository | +| add_issue_comment | Adds a comment to an existing issue | +| search_code | Searches for code within GitHub repositories | +| search_issues | Searches for issues within GitHub repositories | +| search_users | Searches for GitHub users | +| list_commits | Lists commits in a GitHub repository | +| get_issue | Retrieves details of a specific issue | +| get_pull_request | Retrieves details of a specific pull request | +| list_pull_requests | Lists all pull requests in a GitHub repository | +| create_pull_request_review | Creates a review for a pull request | +| merge_pull_request | Merges a pull request in a GitHub repository | +| get_pull_request_files | Retrieves files associated with a pull request | +| get_pull_request_status | Retrieves the status of a pull request | +| update_pull_request_branch | Updates a pull request branch with the latest changes from the base branch | +| get_pull_request_comments | Retrieves comments on a pull request | +| get_pull_request_reviews | Retrieves reviews on a pull request | These tools demonstrate the server's versatility, allowing AI models to perform both simple and complex GitHub operations, such as maintaining Git history without force pushing and handling batch operations for multiple files. #### Setup and Usage + To use the GitHub server, it must be configured with a GitHub Personal Access Token, which is created through [GitHub's documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). The token ensures secure authentication, with permissions configurable for public, private, or selected repositories. For example, for public repositories, the `public_repo` scope is sufficient, while full control requires the `repo` scope. The server can be run using `npx` or Docker, with configuration examples provided for Claude Desktop. An example configuration is: @@ -90,22 +95,27 @@ The server can be run using `npx` or Docker, with configuration examples provide This setup allows AI models in Claude Desktop to interact with GitHub, performing actions like creating issues or updating files. The server also supports advanced search functionality, with syntax details available at [GitHub's searching documentation](https://docs.github.com/en/search-github/searching-on-github). #### Security and Error Handling + The GitHub server is designed with security in mind, featuring comprehensive error handling and automatic branch creation to ensure proper Git history preservation. It avoids force pushing, maintaining the integrity of the repository's commit history. The use of Personal Access Tokens ensures that operations are performed within the bounds of specified permissions, reducing the risk of unauthorized access. #### Community and Ecosystem Insights + The MCP ecosystem, including the GitHub server, is rapidly growing, with community feedback indicating its utility for automating developer workflows. For instance, it can be used to assist in code generation, review, and repository management, saving time for developers. However, some users note that setup can be complex, particularly for those unfamiliar with GitHub tokens or MCP configuration. Companies like Continue have integrated MCP, enhancing AI-assisted coding experiences by leveraging its features for resources, prompts, and tools. This integration is seen as a step toward open standards and developer-owned tools, crucial for shaping the future of AI development. #### Use Cases and Implications + The GitHub server's capabilities open up new possibilities for AI-assisted software development. For example, an AI model could automatically generate code, commit it to a repository, create a pull request, and even request reviews, all through MCP. This automation can streamline workflows, particularly for teams working on large projects, and reduce the manual effort required for version control tasks. An unexpected detail is the server's support for 22 tools, which is more extensive than might be expected for a standard AI integration, enabling complex operations like merging pull requests and searching across repositories, issues, and users. This breadth makes it a powerful tool for automating not just coding but also project management tasks on GitHub. #### Conclusion + The GitHub reference server for MCP is a critical component of the ecosystem, offering a rich set of tools that demonstrate the protocol's potential for integrating AI with version control systems. With its focus on standardization, security, and community collaboration, it is poised to play a significant role in enhancing AI-assisted coding and repository management as of February 27, 2025. #### Key Citations + - [Model Context Protocol Servers GitHub Repository](https://github.com/modelcontextprotocol/servers) - [Model Context Protocol Typescript SDK](https://github.com/modelcontextprotocol/typescript-sdk) - [Model Context Protocol Python SDK](https://github.com/modelcontextprotocol/python-sdk) diff --git a/project-management/research/github-mcp-server-pt2.md b/project-management/research/github-mcp-server-pt2.md index c80b2d5..14fbd00 100644 --- a/project-management/research/github-mcp-server-pt2.md +++ b/project-management/research/github-mcp-server-pt2.md @@ -7,67 +7,73 @@ Certainly! Below, I’ve categorized the tools from the GitHub reference server The tools provided by the GitHub reference server can be grouped into five logical sections based on their functionalities: **File Operations**, **Repository Management**, **Issue Tracking**, **Pull Request Management**, and **Search Functionalities**. Here’s a detailed breakdown of each section with common use cases: #### 1. File Operations -- **Tools**: - - `create_or_update_file` - - `push_files` - - `get_file_contents` -- **Common Use Cases**: - - **Updating Documentation**: Automatically update README files, API documentation, or other markdown files in a repository based on code changes or user prompts. - - **Committing Code Changes**: Generate code snippets and commit them directly to the repository, streamlining the development process. + +- **Tools**: + - `create_or_update_file` + - `push_files` + - `get_file_contents` +- **Common Use Cases**: + - **Updating Documentation**: Automatically update README files, API documentation, or other markdown files in a repository based on code changes or user prompts. + - **Committing Code Changes**: Generate code snippets and commit them directly to the repository, streamlining the development process. - **Retrieving Specific Files**: Fetch files (e.g., configuration files or logs) for analysis, debugging, or generating reports. #### 2. Repository Management -- **Tools**: - - `create_repository` - - `fork_repository` - - `create_branch` -- **Common Use Cases**: - - **Setting Up a New Project Repository**: Create a new repository for a project, initialize it with a template, and set up the necessary branches. - - **Forking an Existing Project**: Fork repositories to contribute to open-source projects or experiment with changes without affecting the original codebase. + +- **Tools**: + - `create_repository` + - `fork_repository` + - `create_branch` +- **Common Use Cases**: + - **Setting Up a New Project Repository**: Create a new repository for a project, initialize it with a template, and set up the necessary branches. + - **Forking an Existing Project**: Fork repositories to contribute to open-source projects or experiment with changes without affecting the original codebase. - **Creating a New Branch for a Feature**: Automate the creation of feature branches, ensuring proper Git workflow and reducing manual setup time. #### 3. Issue Tracking -- **Tools**: - - `create_issue` - - `list_issues` - - `update_issue` - - `add_issue_comment` - - `get_issue` -- **Common Use Cases**: - - **Reporting Bugs**: Automatically create issues when errors or anomalies are detected in the code, complete with relevant details and logs. - - **Assigning Tasks**: Manage team workflows by assigning issues to developers based on their expertise or workload. + +- **Tools**: + - `create_issue` + - `list_issues` + - `update_issue` + - `add_issue_comment` + - `get_issue` +- **Common Use Cases**: + - **Reporting Bugs**: Automatically create issues when errors or anomalies are detected in the code, complete with relevant details and logs. + - **Assigning Tasks**: Manage team workflows by assigning issues to developers based on their expertise or workload. - **Discussing Solutions**: Participate in issue discussions by adding comments with suggestions, code snippets, or links to relevant documentation. #### 4. Pull Request Management -- **Tools**: - - `create_pull_request` - - `list_pull_requests` - - `get_pull_request` - - `create_pull_request_review` - - `merge_pull_request` - - `get_pull_request_files` - - `get_pull_request_status` - - `update_pull_request_branch` - - `get_pull_request_comments` - - `get_pull_request_reviews` -- **Common Use Cases**: - - **Submitting Code for Review**: Generate code, commit it, and automatically create a pull request for human review, speeding up the development cycle. - - **Reviewing and Approving Changes**: Assist in code reviews by checking for common issues, suggesting improvements, or even auto-approving simple changes. + +- **Tools**: + - `create_pull_request` + - `list_pull_requests` + - `get_pull_request` + - `create_pull_request_review` + - `merge_pull_request` + - `get_pull_request_files` + - `get_pull_request_status` + - `update_pull_request_branch` + - `get_pull_request_comments` + - `get_pull_request_reviews` +- **Common Use Cases**: + - **Submitting Code for Review**: Generate code, commit it, and automatically create a pull request for human review, speeding up the development cycle. + - **Reviewing and Approving Changes**: Assist in code reviews by checking for common issues, suggesting improvements, or even auto-approving simple changes. - **Merging Updates**: Once a pull request is approved, merge it into the main branch, ensuring that the latest changes are integrated smoothly. #### 5. Search Functionalities -- **Tools**: - - `search_repositories` - - `search_code` - - `search_issues` - - `search_users` -- **Common Use Cases**: - - **Finding Relevant Repositories**: Search for repositories that match specific criteria (e.g., language, stars) to discover useful projects or libraries. - - **Locating Specific Code Snippets**: Search for code patterns or functions across repositories, aiding in debugging or learning from existing solutions. - - **Identifying Issues to Work On**: Find open issues that match specific skills, making it easier to contribute to open-source projects. + +- **Tools**: + - `search_repositories` + - `search_code` + - `search_issues` + - `search_users` +- **Common Use Cases**: + - **Finding Relevant Repositories**: Search for repositories that match specific criteria (e.g., language, stars) to discover useful projects or libraries. + - **Locating Specific Code Snippets**: Search for code patterns or functions across repositories, aiding in debugging or learning from existing solutions. + - **Identifying Issues to Work On**: Find open issues that match specific skills, making it easier to contribute to open-source projects. - **Finding Collaborators**: Search for GitHub users with specific expertise or contributions, helping teams find potential collaborators or mentors. --- ### Summary + These five sections—**File Operations**, **Repository Management**, **Issue Tracking**, **Pull Request Management**, and **Search Functionalities**—cover the key aspects of software development on GitHub. Each section groups tools that work together to support specific tasks, from managing files and repositories to tracking issues, handling pull requests, and searching for resources. These tools, when integrated with AI, can significantly enhance productivity, automate repetitive tasks, and improve collaboration in software development workflows. diff --git a/project-management/research/reference-mcp-servers.md b/project-management/research/reference-mcp-servers.md index 826f412..3dbb9eb 100644 --- a/project-management/research/reference-mcp-servers.md +++ b/project-management/research/reference-mcp-servers.md @@ -1,16 +1,20 @@ ### Key Points + - The GitHub repository at [https://github.com/modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) is likely a collection of reference implementations for the Model Context Protocol (MCP), an open standard for connecting AI to data sources. - Research suggests MCP, developed by Anthropic, standardizes how Large Language Models (LLMs) access external tools and data, enhancing their relevance and accuracy. - It seems likely that the repository includes official and community-built servers, with over 19 reference servers and more than 150 third-party integrations, covering services like Google Drive and Slack. - The evidence leans toward MCP using a client-server architecture, with servers providing secure access to data, managed by Anthropic with community contributions under an MIT license. ### Project Overview + The repository at [https://github.com/modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) appears to be a hub for implementing the Model Context Protocol (MCP), an open standard aimed at improving how Large Language Models (LLMs) interact with external data and tools. MCP, developed by Anthropic, seeks to provide a universal way for AI systems to access data securely, reducing the need for custom integrations and enhancing response quality. ### Purpose and Significance + MCP addresses the challenge of LLMs being isolated from data, often trapped behind information silos. By standardizing connections, it enables AI applications to deliver more relevant and accurate outputs, which is particularly useful for AI-powered IDEs, chat interfaces, and custom workflows. This standardization is expected to simplify development and scale integrations across various data sources. ### Technical Details + The protocol likely follows a client-server architecture, where MCP hosts (like AI tools) connect to MCP servers that expose capabilities such as resource subscriptions, tool support, and prompt templates. Messages are structured using JSON-RPC 2.0, ensuring standardized communication. The repository includes implementations using Typescript and Python SDKs, with detailed guides available at [modelcontextprotocol.io/introduction](https://modelcontextprotocol.io/introduction). --- @@ -20,14 +24,17 @@ The protocol likely follows a client-server architecture, where MCP hosts (like This note provides an in-depth exploration of the GitHub repository at [https://github.com/modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers), focusing on its purpose, technical details, and broader implications within the context of the Model Context Protocol (MCP). The analysis is based on available documentation, specifications, and community insights, aiming to offer a thorough understanding for researchers, developers, and AI enthusiasts. #### Introduction to the Repository + The repository, hosted under the organization "modelcontextprotocol," is a collection of reference implementations for MCP, an open protocol designed to facilitate seamless integration between Large Language Models (LLMs) and external data sources and tools. Introduced by Anthropic on November 24, 2024, MCP addresses the challenge of LLMs being constrained by data isolation, often trapped behind information silos and legacy systems. The repository serves as a practical demonstration of MCP's versatility, showcasing how it can be implemented for various use cases. #### Purpose and Significance of MCP + MCP aims to provide a universal, open standard for connecting AI systems with data sources, replacing fragmented integrations with a single protocol. This standardization is crucial for enhancing the relevance and accuracy of LLM responses, particularly in applications like AI-powered IDEs, chat interfaces, and custom AI workflows. By enabling secure and controlled access to data, MCP helps break down data silos, making it easier for developers to build scalable and connected AI systems. Early adopters, including companies like Block, Apollo, Zed, Replit, Codeium, and Sourcegraph, have integrated MCP into their platforms, highlighting its potential to revolutionize AI-assisted development. The protocol's significance lies in its ability to simplify integrations, reduce development overhead, and unlock real-time data access for LLMs. For instance, it allows AI systems to fetch files, query databases, or make API requests, enhancing their utility in enterprise settings and beyond. This is particularly important as AI adoption grows, and the need for robust, standardized data connections becomes more pressing. #### Technical Details and Architecture + MCP operates on a client-server architecture, where MCP hosts (such as Claude Desktop, IDEs, or AI tools) connect to MCP servers that expose specific capabilities. These servers are lightweight programs that provide access to local data sources (e.g., files, databases) and remote services (e.g., APIs). The protocol uses JSON-RPC 2.0 for message structure and delivery semantics, ensuring standardized communication. A key feature is its capability-based negotiation system, where clients and servers explicitly declare their supported features during initialization. Servers can declare capabilities like resource subscriptions, tool support, and prompt templates, while clients declare capabilities like sampling support and notification handling. Both parties must respect declared capabilities throughout the session, with additional capabilities negotiable through protocol extensions. @@ -35,67 +42,73 @@ A key feature is its capability-based negotiation system, where clients and serv The repository includes 19 official reference servers, such as AWS KB Retrieval, Brave Search, Google Drive, GitHub, GitLab, Google Maps, PostgreSQL, Slack, and Sqlite, all implemented using either the Typescript MCP SDK ([https://github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)) or the Python MCP SDK ([https://github.com/modelcontextprotocol/python-sdk](https://github.com/modelcontextprotocol/python-sdk)). These servers demonstrate MCP's versatility for giving LLMs secure, controlled access to tools and data sources. #### Implementation Details and Integrations + The repository is not limited to official servers; it also hosts a vast ecosystem of third-party integrations. There are 31 official integrations, including 21st.dev Magic, Apify, Cloudflare, Exa, Firecrawl, Grafana, IBM wxflows, JetBrains, Kagi Search, Meilisearch, Neo4j, Qdrant, Stripe, and Tavily, with GitHub URLs provided for each (e.g., [21st.dev Magic](https://github.com/21st-dev/magic-mcp)). Additionally, there are over 100 community servers, covering services like AWS S3, Airtable, Anki, ArangoDB, Atlassian, BigQuery, Calendar, Discord, Docker, Elasticsearch, Gmail, MongoDB, Notion, Pinecone, Redis, Salesforce MCP, Snowflake, Spotify, and YouTube, with GitHub URLs for each (e.g., [AWS S3](https://github.com/aws-samples/sample-mcp-server-s3)). These servers are located at `/modelcontextprotocol/servers/blob/main/src/` followed by the respective server name, making it easy for developers to explore and contribute. The repository also includes high-level frameworks for building MCP servers or clients, such as the CodeMirror extension for MCP at [codemirror-mcp](https://github.com/marimo-team/codemirror-mcp). #### Getting Started and Contribution Guidelines + For developers looking to get started, the repository provides clear instructions. Typescript servers can be used with `npx`, e.g., `npx -y @modelcontextprotocol/server-memory`, while Python servers can be installed with `uvx` or `pip`, e.g., `uvx mcp-server-git` or `pip install mcp-server-git; python -m mcp_server_git`. Installation guides for `uv`/`uvx` are available at [Astral Docs](https://docs.astral.sh/uv/getting-started/installation/), and for `pip` at [PyPA](https://pip.pypa.io/en/stable/installation/). Example configurations, such as for Claude Desktop with memory, filesystem, git, GitHub, and Postgres servers, are provided to help users begin. Contribution is encouraged, with guidelines detailed in [CONTRIBUTING.md](https://github.com/modelcontextprotocol/servers/blob/main/CONTRIBUTING.md). The project is open-source under the MIT License, with security guidelines for reporting vulnerabilities in [SECURITY.md](https://github.com/modelcontextprotocol/servers/blob/main/SECURITY.md). Anthropic manages the project, but it is built with community contributions, fostering a collaborative ecosystem. #### Community and Ecosystem Insights + The MCP ecosystem is rapidly growing, with community feedback indicating both potential and challenges. For instance, some users have found early implementations useful for tasks like connecting AI to browsing history, but others note that the user experience can be rough, with setup processes being arduous. Despite this, the protocol's open nature and community-driven development suggest a promising future, particularly for hobbyists and developers building custom AI solutions. Companies like Continue have already integrated MCP, enhancing AI-assisted coding experiences by leveraging its features for resources, prompts, tools, and sampling. This integration is seen as a step toward open standards and developer-owned tools, crucial for shaping the future of AI development. #### Detailed Tables of Servers and Integrations + Below are tables summarizing the reference servers and a sample of official and community integrations, based on the repository's README content: -| **Reference Servers (19)** | **Description** | -|----------------------------|-----------------| -| AWS KB Retrieval | Retrieves knowledge from AWS KB | +| **Reference Servers (19)** | **Description** | +| -------------------------- | ----------------------------------- | +| AWS KB Retrieval | Retrieves knowledge from AWS KB | | Brave Search | Integrates with Brave Search engine | -| EverArt | Art-related data access | -| Everything | General-purpose search | -| Fetch | Data fetching capabilities | -| Filesystem | Access to local file systems | -| Git | Git repository access | -| GitHub | GitHub API integration | -| GitLab | GitLab API integration | -| Google Drive | Google Drive file access | -| Google Maps | Google Maps data access | -| Memory | Memory management for LLMs | -| PostgreSQL | PostgreSQL database access | -| Puppeteer | Web scraping with Puppeteer | -| Sentry | Error tracking integration | -| Sequential Thinking | Supports sequential reasoning | -| Slack | Slack API integration | -| Sqlite | SQLite database access | -| Time | Time-related data access | - -| **Official Integrations (Sample, 31 Total)** | **GitHub URL Example** | -|---------------------------------------------|-----------------------| +| EverArt | Art-related data access | +| Everything | General-purpose search | +| Fetch | Data fetching capabilities | +| Filesystem | Access to local file systems | +| Git | Git repository access | +| GitHub | GitHub API integration | +| GitLab | GitLab API integration | +| Google Drive | Google Drive file access | +| Google Maps | Google Maps data access | +| Memory | Memory management for LLMs | +| PostgreSQL | PostgreSQL database access | +| Puppeteer | Web scraping with Puppeteer | +| Sentry | Error tracking integration | +| Sequential Thinking | Supports sequential reasoning | +| Slack | Slack API integration | +| Sqlite | SQLite database access | +| Time | Time-related data access | + +| **Official Integrations (Sample, 31 Total)** | **GitHub URL Example** | +| -------------------------------------------- | ------------------------------------------------------- | | 21st.dev Magic | [21st.dev Magic](https://github.com/21st-dev/magic-mcp) | -| Apify | [Apify](https://github.com/apify/apify-mcp) | -| Cloudflare | [Cloudflare](https://github.com/cloudflare/mcp-server) | -| Exa | [Exa](https://github.com/exa-labs/exa-mcp) | -| Firecrawl | [Firecrawl](https://github.com/firecrawl/firecrawl-mcp) | +| Apify | [Apify](https://github.com/apify/apify-mcp) | +| Cloudflare | [Cloudflare](https://github.com/cloudflare/mcp-server) | +| Exa | [Exa](https://github.com/exa-labs/exa-mcp) | +| Firecrawl | [Firecrawl](https://github.com/firecrawl/firecrawl-mcp) | -| **Community Servers (Sample, 100+ Total)** | **GitHub URL Example** | -|-------------------------------------------|-----------------------| +| **Community Servers (Sample, 100+ Total)** | **GitHub URL Example** | +| ------------------------------------------ | ------------------------------------------------------------- | | AWS S3 | [AWS S3](https://github.com/aws-samples/sample-mcp-server-s3) | -| Airtable | [Airtable](https://github.com/airtable/airtable-mcp) | -| Anki | [Anki](https://github.com/anki/anki-mcp) | -| ArangoDB | [ArangoDB](https://github.com/arangodb/arango-mcp) | -| Discord | [Discord](https://github.com/discord/discord-mcp) | +| Airtable | [Airtable](https://github.com/airtable/airtable-mcp) | +| Anki | [Anki](https://github.com/anki/anki-mcp) | +| ArangoDB | [ArangoDB](https://github.com/arangodb/arango-mcp) | +| Discord | [Discord](https://github.com/discord/discord-mcp) | These tables illustrate the breadth of the ecosystem, highlighting the diversity of data sources and tools supported by MCP. #### Conclusion + The GitHub repository at [https://github.com/modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) is a critical component of the MCP ecosystem, offering a rich set of implementations that demonstrate the protocol's potential. With its focus on standardization, security, and community collaboration, MCP is poised to play a significant role in the future of AI development, particularly in enhancing LLM capabilities through seamless data integration. #### Key Citations + - [Model Context Protocol Servers GitHub Repository](https://github.com/modelcontextprotocol/servers) - [Model Context Protocol Introduction](https://modelcontextprotocol.io/introduction) - [Model Context Protocol Typescript SDK](https://github.com/modelcontextprotocol/typescript-sdk) diff --git a/project-management/research/research-plan.md b/project-management/research/research-plan.md index 8d5b115..f0f33d4 100644 --- a/project-management/research/research-plan.md +++ b/project-management/research/research-plan.md @@ -5,12 +5,15 @@ Below is a detailed research plan designed to help you gather the essential know ## Research Plan: Building an Azure DevOps MCP Server ### Objective + Gather the necessary knowledge and resources to design and implement an Azure DevOps reference server for the Model Context Protocol (MCP), focusing on core functionality, repository operations, work items, pipelines, and security. ### Research Tasks (7 Total) + These tasks are structured to be completed sequentially, providing a clear path from understanding the basics to validating your approach. #### 1. Understand the MCP Protocol and SDK + - **Goal**: Learn how MCP servers function and integrate with clients like Claude Desktop. - **Actions**: - Read the [MCP Introduction](https://modelcontextprotocol.io/introduction) to understand its purpose and architecture. @@ -19,6 +22,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: A solid grasp of MCP’s client-server model and initial ideas for structuring your server. #### 2. Explore Azure DevOps REST APIs + - **Goal**: Identify the key APIs for core functionality, repositories, work items, and pipelines. - **Actions**: - Study the [Azure DevOps REST API overview](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1). @@ -31,6 +35,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: A list of API endpoints tied to specific server tools (e.g., `list_projects`). #### 3. Investigate Authentication Methods + - **Goal**: Determine how to securely authenticate with Azure DevOps APIs. - **Actions**: - Read the [Azure DevOps authentication guidance](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/authentication-guidance?view=azure-devops). @@ -42,6 +47,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: A chosen initial auth method (e.g., PAT) with notes for future expansion. #### 4. Study the `azure-devops-node-api` Library + - **Goal**: Understand how to use this Typescript library to interact with Azure DevOps APIs. - **Actions**: - Visit the [library’s GitHub page](https://github.com/microsoft/azure-devops-node-api). @@ -50,6 +56,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: Ability to use the library as the foundation for your server’s API interactions. #### 5. Analyze the GitHub MCP Server for Patterns + - **Goal**: Learn best practices from an existing MCP server implementation. - **Actions**: - Study the [GitHub server source code](https://github.com/modelcontextprotocol/servers/tree/main/src/github). @@ -60,6 +67,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: A template for organizing your server’s tools and setup. #### 6. Research Security Best Practices + - **Goal**: Ensure your server handles permissions and operations securely. - **Actions**: - Review [security in Azure DevOps integrations](https://learn.microsoft.com/en-us/azure/devops/organizations/security/about-security-identity?view=azure-devops). @@ -70,6 +78,7 @@ These tasks are structured to be completed sequentially, providing a clear path - **Outcome**: A checklist of security practices to implement during development. #### 7. Validate Use Case Feasibility + - **Goal**: Confirm that a sample use case (e.g., user story to pull request) works with your tools. - **Actions**: - Map the use case steps to specific tools and APIs (e.g., create work item → `POST /wit/workitems`, link to PR → `PATCH /git/pullrequests`). @@ -80,8 +89,9 @@ These tasks are structured to be completed sequentially, providing a clear path --- ### Plan Summary -- **Tasks**: 7 targeted research steps. -- **Time Estimate**: 1–2 hours per task, totaling 7–14 hours. + +- **Tasks**: 7 targeted research steps. +- **Time Estimate**: 1–2 hours per task, totaling 7–14 hours. - **Next Steps**: Once these tasks are complete, you’ll have the knowledge to start prototyping the server, beginning with core functionality and expanding from there. This plan keeps research focused and efficient, giving you a strong foundation to build your Azure DevOps MCP server without getting lost in the weeds. If you’d like to tweak any tasks or add specific areas of focus, just let me know! diff --git a/project-management/research/research-pt1.md b/project-management/research/research-pt1.md index 469891d..0626c8d 100644 --- a/project-management/research/research-pt1.md +++ b/project-management/research/research-pt1.md @@ -1,4 +1,5 @@ ### Key Points + - Research suggests the Model Context Protocol (MCP) is an open standard for connecting AI to data sources, using a client-server model with JSON-RPC 2.0, and its Typescript SDK simplifies server building. - It seems likely that Azure DevOps authentication can use Personal Access Tokens (PATs) for simplicity or Azure Active Directory (AAD) tokens for enhanced security, with the server configurable via environment variables. - The evidence leans toward security best practices including storing credentials in environment variables, scoping permissions minimally, and logging actions for auditing, ensuring safe and secure server operations. @@ -6,12 +7,15 @@ --- ### Understanding the MCP Protocol and SDK + The Model Context Protocol (MCP) is designed to connect AI models, like those in Claude Desktop, to external data sources in a standardized, secure way. It uses a client-server model where the AI client communicates with the server via JSON-RPC 2.0, a protocol for remote procedure calls. The Typescript SDK, available at [github.com/modelcontextprotocol/typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk), makes building servers easier by letting you define tools—specific operations like listing projects—with names, handler functions, and input schemas. You create the server using the `getMcpServer` function, and the SDK handles the underlying socket connections and JSON-RPC messaging, so you can focus on implementing the tools. ### Investigating Authentication Methods for Azure DevOps + For your server to interact with Azure DevOps, it needs secure authentication. Research suggests two main methods: Personal Access Tokens (PATs) and Azure Active Directory (AAD) tokens. PATs are simple, stored in environment variables like `AZURE_DEVOPS_PAT`, and work well with the `azure-devops-node-API` library for local setups. For enhanced security, AAD tokens can be used, requiring the `@azure/identity` package to get tokens via service principals, but this needs custom handling with libraries like Axios for API calls, as the `WebApi` class primarily supports PATs. The server can be configured to choose between these methods via environment variables, ensuring flexibility for users. ### Researching Security Best Practices + To keep your server secure, store credentials like PATs and AAD secrets in environment variables, ensuring they’re not exposed in code. Scope permissions to the minimum needed, such as read-only for listing projects, to reduce risks. Handle errors gracefully with try-catch blocks, logging issues without leaking sensitive data, and ensure operations are safe, like creating new commits instead of force pushing. Log all AI actions for auditing, be mindful of API rate limits with retry logic, and use secure communication channels, though local setups may rely on trusted environments. --- @@ -21,6 +25,7 @@ To keep your server secure, store credentials like PATs and AAD secrets in envir This note provides an in-depth exploration of three research tasks—understanding the Model Context Protocol (MCP) and its SDK, investigating authentication methods for Azure DevOps, and researching security best practices—for building an Azure DevOps reference server for MCP as of 01:20 AM EST on Thursday, February 27, 2025. The analysis covers protocol architecture, SDK usage, authentication strategies, and security measures, ensuring a thorough foundation for server implementation. #### Task 1: Understand the MCP Protocol and SDK + The Model Context Protocol (MCP), introduced by Anthropic on November 24, 2024, is an open standard for connecting AI models to external data sources, addressing the challenge of data isolation in AI applications. The official introduction at [modelcontextprotocol.io/introduction](https://modelcontextprotocol.io/introduction) explains that MCP uses a client-server architecture, where the client (e.g., Claude Desktop) connects to the server to access capabilities like resource subscriptions, tool support, and prompt templates. Communication is facilitated by JSON-RPC 2.0, a protocol for remote procedure calls, ensuring standardized message structure and delivery semantics. The Typescript SDK, hosted at [GitHub - modelcontextprotocol/typescript-sdk: Official Typescript SDK for building MCP servers and clients](https://github.com/modelcontextprotocol/typescript-sdk), simplifies server development. The README details that it provides a framework for defining and implementing MCP tools, which are specific operations the server offers to the AI client. To create a server, developers use the `getMcpServer` function, as seen in the example: @@ -46,6 +51,7 @@ Each tool is defined with a name, an async handler function that performs the op Key considerations include ensuring tools are async to handle API calls, defining clear input schemas for MCP compliance, and leveraging the SDK’s built-in error handling. This research equips you to structure the Azure DevOps server, defining tools like `list_projects` and `create_work_item` with handlers that interact with Azure DevOps APIs. #### Task 3: Investigate Authentication Methods + Authentication is critical for the server to securely access Azure DevOps resources on behalf of the user. Research into Azure DevOps authentication, detailed at [Azure DevOps Authentication Guidance | Microsoft Learn](https://learn.microsoft.com/en-us/azure/devops/integrate/get-started/authentication/authentication-guidance?view=azure-devops), identifies three main methods: Personal Access Tokens (PATs), Azure Active Directory (AAD) tokens, and managed identities. Given the server’s local deployment requirement, PATs and AAD tokens are relevant, with managed identities more suited for cloud environments. PATs are straightforward, generated by users with specific scopes (e.g., read, write), and stored in environment variables like `AZURE_DEVOPS_PAT`. The `azure-devops-node-API` library, studied in Task 4, natively supports PATs, as seen in its constructor: @@ -53,7 +59,10 @@ PATs are straightforward, generated by users with specific scopes (e.g., read, w ```ts import { WebApi } from 'azure-devops-node-API'; -const api = new WebApi(`https://dev.azure.com/${organization}`, process.env.AZURE_DEVOPS_PAT); +const api = new WebApi( + `https://dev.azure.com/${organization}`, + process.env.AZURE_DEVOPS_PAT, +); ``` This approach is simple, aligning with the GitHub MCP server’s use of environment variables for tokens. However, for enhanced security, AAD tokens are necessary, requiring the `@azure/identity` package, available at [GitHub - Azure/azure-sdk-for-js: This repository is for active development of the Azure SDK for JavaScript](https://github.com/Azure/azure-sdk-for-js). Using `ClientSecretCredential`, developers can obtain an AAD token: @@ -61,21 +70,31 @@ This approach is simple, aligning with the GitHub MCP server’s use of environm ```ts import { ClientSecretCredential } from '@azure/identity'; -const credential = new ClientSecretCredential(process.env.AZURE_TENANT_ID, process.env.AZURE_APP_ID, process.env.AZURE_APP_SECRET); -const token = await credential.getToken('https://devops.artifacts.visualstudio.com'); +const credential = new ClientSecretCredential( + process.env.AZURE_TENANT_ID, + process.env.AZURE_APP_ID, + process.env.AZURE_APP_SECRET, +); +const token = await credential.getToken( + 'https://devops.artifacts.visualstudio.com', +); ``` The challenge is that `WebApi` primarily supports PATs, using them in `Basic` authentication, as seen in its source code at [Azure Devops Node API GitHub Repository](https://github.com/Microsoft/azure-devops-node-API). For AAD, tokens must use `Bearer` authorization, necessitating custom API calls with Axios, as shown: ```ts -const response = await Axios.get(`https://dev.azure.com/${organization}/_apis/projects`, { - headers: { Authorization: `Bearer ${token.token}` }, -}); +const response = await Axios.get( + `https://dev.azure.com/${organization}/_apis/projects`, + { + headers: { Authorization: `Bearer ${token.token}` }, + }, +); ``` To support both, the server can use environment variables like `AZURE_DEVOPS_METHOD` (“PAT” or “AAD”) to conditionally initialize the API client, ensuring flexibility. For PAT, use `WebApi`; for AAD, create a custom API object with Axios, maintaining a unified interface. This approach meets the user’s requirement for all standard strategies, though initial implementation may prioritize PAT for simplicity, with AAD as a future enhancement. #### Task 6: Research Security Best Practices + Security is paramount for the server, given its interaction with sensitive Azure DevOps data. Research into best practices, guided by [Security in Azure DevOps Integrations | Microsoft Learn](https://learn.microsoft.com/en-us/azure/devops/organizations/security/about-security-identity?view=azure-devops), covers credential storage, permission scoping, error handling, safe operations, logging, rate limiting, and communication security. First, storing credentials securely involves using environment variables, as seen in MCP server examples, ensuring they’re not committed to version control. For enhanced security, consider integrating with secure vaults, though for local setups, environment variables suffice, assuming a trusted environment. Second, scoping permissions requires PATs to have minimal scopes (e.g., read for `list_projects`, write for `create_work_item`), and AAD service principals to have role-based access control (RBAC) with least privilege, reducing unauthorized access risks. @@ -98,9 +117,11 @@ Sixth, rate limiting and API usage require awareness of Azure DevOps’ subscrip This research ensures the server is secure, reliable, and compliant, supporting the end-to-end use case from user story to pull request with minimal risk. #### Conclusion + This research provides a comprehensive foundation for building the Azure DevOps MCP server, covering MCP protocol understanding, authentication flexibility, and security robustness. The Typescript SDK simplifies tool implementation, PAT and AAD authentication meet user needs, and security practices ensure safe operations, equipping you to proceed with prototyping as of 01:20 AM EST on February 27, 2025. #### Key Citations + - [Model Context Protocol Introduction](https://modelcontextprotocol.io/introduction) - [GitHub - modelcontextprotocol/typescript-sdk: Official Typescript SDK for building MCP servers and clients](https://github.com/modelcontextprotocol/typescript-sdk) - [GitHub - modelcontextprotocol/servers: Reference implementations of the Model Context Protocol (MCP) servers](https://github.com/modelcontextprotocol/servers) @@ -108,4 +129,4 @@ This research provides a comprehensive foundation for building the Azure DevOps - [GitHub - Azure/azure-sdk-for-js: This repository is for active development of the Azure SDK for JavaScript](https://github.com/Azure/azure-sdk-for-js) - [Azure Devops Node API GitHub Repository](https://github.com/Microsoft/azure-devops-node-API) - [Security in Azure DevOps Integrations | Microsoft Learn](https://learn.microsoft.com/en-us/azure/devops/organizations/security/about-security-identity?view=azure-devops) -- [Azure DevOps REST API Rate Limits | Microsoft Learn](https://learn.microsoft.com/en-us/azure/devops/integrate/concepts/rate-limits?view=azure-devops) \ No newline at end of file +- [Azure DevOps REST API Rate Limits | Microsoft Learn](https://learn.microsoft.com/en-us/azure/devops/integrate/concepts/rate-limits?view=azure-devops) diff --git a/project-management/research/research-pt2.md b/project-management/research/research-pt2.md index 7915fa5..7e23732 100644 --- a/project-management/research/research-pt2.md +++ b/project-management/research/research-pt2.md @@ -5,25 +5,32 @@ Adding tools for reading pull request comments is a smart idea and would enhance ### Analysis: Should We Add Tools for Reading Pull Request Comments? #### Why It Makes Sense + - **Context Awareness**: Reading pull request comments allows the AI to gain insight into reviewer feedback, pipeline outcomes, or conflict notifications, enabling more informed decisions (e.g., whether to merge, revise code, or escalate issues). - **Workflow Completeness**: The current use case assumes the AI creates and merges pull requests, but without reading comments, it might miss critical information, like a reviewer’s “needs changes” note or a pipeline failure explanation posted as a comment. - **Edge Case Handling**: For cases like pipeline failures or merge conflicts, comments often contain details the AI can use to respond appropriately—pairing well with `add_pull_request_comment`. - **Parity with GitHub Server**: The GitHub MCP server includes tools like `get_pull_request_comments`, suggesting this is a standard feature for robust PR management, which your server should match or exceed. #### Proposed Tools + To fully support reading pull request comments, two tools seem practical: + 1. **Get Pull Request Comments**: Fetch all comments on a pull request to review feedback or status updates. 2. **Get Pull Request Comment**: Retrieve a specific comment by ID for targeted analysis (e.g., following up on a known issue). However, for simplicity and immediate utility in the use case, starting with just `get_pull_request_comments` might suffice, as it covers the broader need. The singular version can be added later if precision becomes necessary. #### API Support + The Azure DevOps REST API provides endpoints for this: + - **List Pull Request Comments**: `GET https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullRequests/{pullRequestId}/threads` retrieves all comment threads on a pull request, including comments and their metadata (e.g., author, timestamp). - **Get Specific Comment**: While not directly a single endpoint, filtering the threads response by thread ID or comment ID can achieve this, though it’s less critical for initial implementation. #### Impact on Use Case + Adding `get_pull_request_comments` enhances the “Wait for Pipeline and Merge” step (Step 7). The AI can: + - Check comments for pipeline results or reviewer approval (common in Azure DevOps workflows where automated systems post status). - Decide to merge only if comments indicate positive feedback or pipeline success, improving decision-making over just relying on `get_pipeline_status`. @@ -34,6 +41,7 @@ Adding `get_pull_request_comments` enhances the “Wait for Pipeline and Merge Below is the revised analysis from Task #7, incorporating the addition of tools for reading pull request comments. Only the relevant sections are updated; unchanged sections (e.g., Introduction, Edge Cases) are summarized for brevity. #### Use Case Steps and Tool Mapping (Updated) + The workflow remains eight steps, with Step 7 refined to include the new tool: 1. **User Provides a Request or User Story**: No tool needed; AI input. @@ -42,37 +50,40 @@ The workflow remains eight steps, with Step 7 refined to include the new tool: 4. **AI Commits the Code to a New Branch**: `create_branch`, `push_changes` (or `create_or_update_file`) 5. **AI Triggers a Pipeline to Test the Code**: `trigger_pipeline` (optional if auto-triggered) 6. **AI Creates a Pull Request Linking It to the User Story**: `create_pull_request` -7. **AI Waits for Pipeline to Pass and Then Merges the Pull Request**: - - Tools: `get_pipeline_status`, `get_pull_request_comments` (new), `merge_pull_request` +7. **AI Waits for Pipeline to Pass and Then Merges the Pull Request**: + - Tools: `get_pipeline_status`, `get_pull_request_comments` (new), `merge_pull_request` - The AI uses `get_pipeline_status` to check build success and `get_pull_request_comments` to review feedback (e.g., “Pipeline passed” or “Needs fixes”). If both indicate approval, it calls `merge_pull_request`. 8. **AI Updates the User Story to Reflect Completion**: `update_work_item` #### Identified Gaps and Additional Tools (Updated) + The previous gap led to `add_pull_request_comment`. This research adds: -- **Get Pull Request Comments**: - - Tool: `get_pull_request_comments` - - API: `GET https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullRequests/{pullRequestId}/threads` - - Use: Fetches all comment threads on a pull request, enabling the AI to read reviewer feedback or pipeline status comments. +- **Get Pull Request Comments**: + - Tool: `get_pull_request_comments` + - API: `GET https://dev.azure.com/{organization}/{project}/_apis/git/repositories/{repositoryId}/pullRequests/{pullRequestId}/threads` + - Use: Fetches all comment threads on a pull request, enabling the AI to read reviewer feedback or pipeline status comments. - Example Response: Returns an array of threads, each with comments, authors, and timestamps. While `get_pull_request_comment` (for a single comment by ID) could be useful, it’s less urgent for the initial use case, as `get_pull_request_comments` covers the need to scan all feedback. This keeps the toolset lean while addressing the gap. #### Refined Tool List (Updated) + The updated tool list now includes both comment-related tools: -| **Section** | **Tools** | -|----------------------------------|------------------------------------| -| Core Functionality | list_organizations, list_projects, list_repositories, get_project_details, get_repository_details | -| Repository Operations | create_or_update_file, push_changes, get_file_contents | +| **Section** | **Tools** | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core Functionality | list_organizations, list_projects, list_repositories, get_project_details, get_repository_details | +| Repository Operations | create_or_update_file, push_changes, get_file_contents | | Branch and Pull Request Management | create_branch, create_pull_request, merge_pull_request, get_pull_request, list_pull_requests, add_pull_request_comment, get_pull_request_comments | -| Work Item Management | create_work_item, update_work_item, list_work_items, get_work_item, add_work_item_comment | -| Pipeline Interactions | trigger_pipeline, get_pipeline_status, list_pipelines | -| Search and Query | search_code, search_work_items, search_wiki | +| Work Item Management | create_work_item, update_work_item, list_work_items, get_work_item, add_work_item_comment | +| Pipeline Interactions | trigger_pipeline, get_pipeline_status, list_pipelines | +| Search and Query | search_code, search_work_items, search_wiki | Total tools: ~30, reflecting Azure DevOps’ complexity and parity with the GitHub server’s capabilities. #### Workflow Diagram Description (Updated) + The revised workflow is: 1. User inputs a user story (e.g., “Add login feature”). @@ -87,18 +98,22 @@ The revised workflow is: This ensures the AI reacts to both pipeline status and human/AI comments, enhancing robustness. #### Edge Cases and Considerations (Updated) + - **Pipeline Failure**: Now, if `get_pipeline_status` shows failure, `get_pull_request_comments` can confirm details (e.g., a comment from the pipeline bot), and `add_pull_request_comment` notifies accordingly. - **Reviewer Feedback**: If `get_pull_request_comments` reveals “needs changes,” the AI can pause merging, comment back, or adjust code—though code adjustment might be future work. #### Conclusion + Adding `get_pull_request_comments` makes the use case more robust, allowing the AI to read feedback and make informed decisions, especially in Step 7. The workflow remains feasible, with the server now better equipped to handle collaboration and edge cases like pipeline failures or reviewer input. The single-tool addition keeps complexity manageable while significantly improving functionality. #### Key Citations + - [Azure DevOps REST API Documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1) --- ### Recommendation + Yes, we should add `get_pull_request_comments` to the server’s toolset. It’s a lightweight, high-value addition that aligns with the GitHub server’s precedent and enhances the AI’s ability to manage pull requests effectively. The singular `get_pull_request_comment` can be deferred unless specific comment targeting becomes critical later. What do you think—should we finalize this toolset and move to prototyping, or tackle another research task first? diff --git a/project-management/research/research-pt3.md b/project-management/research/research-pt3.md index f4ea24f..292f2dc 100644 --- a/project-management/research/research-pt3.md +++ b/project-management/research/research-pt3.md @@ -1,4 +1,5 @@ ### Key Points + - Research suggests the Azure DevOps MCP server can support the end-to-end use case of going from a user story to a pull request, using tools like creating work items and merging pull requests. - It seems likely that the server needs additional tools, like adding comments to pull requests, to handle edge cases such as pipeline failures. - The evidence leans toward the workflow being feasible, with the AI able to create, commit, and merge changes, though waiting for pipeline completion may require external handling. @@ -8,10 +9,13 @@ ### Direct Answer #### Overview + The Azure DevOps MCP server can likely handle the end-to-end use case where an AI goes from creating a user story to merging a pull request. This process involves several steps, each supported by specific tools in the server, ensuring the AI can automate the workflow seamlessly. #### Use Case Steps and Tools + Here’s how the process works: + - **Create a User Story**: The AI uses the `create_work_item` tool to log a new user story in Azure DevOps, setting the stage for development. - **Generate and Commit Code**: The AI generates code internally, then uses `create_branch` to start a new branch and `push_changes` to commit multiple files, or `create_or_update_file` for a single file, ensuring changes are tracked. - **Create and Link a Pull Request**: The AI uses `create_pull_request` to submit the changes, linking it to the user story for tracking, with the pipeline likely triggered automatically. @@ -19,9 +23,11 @@ Here’s how the process works: - **Update the User Story**: Finally, the AI uses `update_work_item` to mark the user story as completed, closing the loop. #### Unexpected Detail + An interesting aspect is that the server needed a new tool, `add_pull_request_comment`, to handle edge cases like pipeline failures, allowing the AI to comment on pull requests if something goes wrong, which wasn’t initially planned. #### Considerations + The workflow assumes the pipeline triggers on pull request creation, but if manual triggering is needed, the AI can use `trigger_pipeline`. Waiting for the pipeline to complete might require the AI to periodically check `get_pipeline_status`, which could be handled externally for simplicity. For more details, check the [Azure DevOps REST API documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1). @@ -33,107 +39,125 @@ For more details, check the [Azure DevOps REST API documentation](https://learn. This note provides an in-depth exploration of the feasibility of the end-to-end use case for the Azure DevOps reference server for the Model Context Protocol (MCP), focusing on the AI-driven workflow from creating a user story to merging a pull request. The analysis, conducted as of 01:12 AM EST on Thursday, February 27, 2025, maps the use case steps to server tools, identifies gaps, considers edge cases, and refines the tool list, ensuring a thorough understanding for implementation. #### Introduction + The use case involves an AI, such as one integrated with Claude Desktop, autonomously handling the development lifecycle from a user-provided request or user story to merging a pull request in Azure DevOps. This research validates whether the planned tools for the MCP server can support this workflow, aligning with the goal of automating software development tasks. The process includes creating work items, committing code, triggering pipelines, and managing pull requests, with a focus on ensuring feasibility and identifying any gaps. #### Use Case Steps and Tool Mapping + The use case is broken into eight steps, each mapped to specific tools from the server’s toolset. The mapping is as follows: -1. **User Provides a Request or User Story**: +1. **User Provides a Request or User Story**: + - This step is an input to the AI, requiring no server tool. The AI receives the user story, such as “Add login feature,” and prepares to act. -2. **AI Creates a New User Story**: - - Tool: `create_work_item` - - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems` +2. **AI Creates a New User Story**: + + - Tool: `create_work_item` + - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems` - The AI uses this tool to create a new work item, such as a user story, with fields like title and description, setting the foundation for the task. -3. **AI Generates Code**: +3. **AI Generates Code**: + - No tool needed; this is handled internally by the AI. The AI generates code based on the user story, such as implementing a login feature, which it will commit later. -4. **AI Commits the Code to a New Branch**: - - Tools: `create_branch` and `push_changes` (for multiple files) or `create_or_update_file` (for a single file) - - API for `create_branch`: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/refs` (creates a new branch ref) - - API for `push_changes`: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Commits` (creates a commit with multiple file changes) - - API for `create_or_update_file`: `PUT https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Files/{path}` (creates or updates a file, auto-committing) +4. **AI Commits the Code to a New Branch**: + + - Tools: `create_branch` and `push_changes` (for multiple files) or `create_or_update_file` (for a single file) + - API for `create_branch`: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/refs` (creates a new branch ref) + - API for `push_changes`: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Commits` (creates a commit with multiple file changes) + - API for `create_or_update_file`: `PUT https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Files/{path}` (creates or updates a file, auto-committing) - The research confirmed that `create_or_update_file` can create a branch if it doesn’t exist by specifying the branch name, but for clarity and batch operations, `create_branch` followed by `push_changes` is preferred for multiple files. For example, the AI creates a branch “feature/login” and commits multiple files in one go using `push_changes`. -5. **AI Triggers a Pipeline to Test the Code**: - - Tool: `trigger_pipeline` - - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Build/Builds` +5. **AI Triggers a Pipeline to Test the Code**: + + - Tool: `trigger_pipeline` + - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Build/Builds` - The AI can manually trigger a build pipeline to test the code. However, research suggests that in many setups, pipelines are configured to trigger automatically on pull request creation, potentially making this step optional. For generality, the tool is retained. -6. **AI Creates a Pull Request Linking It to the User Story**: - - Tool: `create_pull_request` - - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests` +6. **AI Creates a Pull Request Linking It to the User Story**: + + - Tool: `create_pull_request` + - API: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests` - The AI submits a pull request, specifying the source and target branches (e.g., “feature/login” to “main”), and links it to the user story by including `workItemIds` in the request body, ensuring traceability. -7. **AI Waits for Pipeline to Pass and Then Merges the Pull Request**: - - Tools: `get_pipeline_status` and `merge_pull_request` - - API for `get_pipeline_status`: `GET https://dev.azure.com/{organization}/{project}/_apis/Build/Builds/{buildId}` - - API for `merge_pull_request`: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests/{pullRequestId}/merge` +7. **AI Waits for Pipeline to Pass and Then Merges the Pull Request**: + + - Tools: `get_pipeline_status` and `merge_pull_request` + - API for `get_pipeline_status`: `GET https://dev.azure.com/{organization}/{project}/_apis/Build/Builds/{buildId}` + - API for `merge_pull_request`: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests/{pullRequestId}/merge` - The AI checks the pipeline status to ensure it passed, which may require periodic calls to `get_pipeline_status` due to the asynchronous nature of pipeline runs. If passed, the AI merges the pull request. Research notes that waiting for pipeline completion might be handled externally by the AI, as MCP tools are expected to be quick, suggesting a delay or polling strategy. -8. **AI Updates the User Story to Reflect Completion**: - - Tool: `update_work_item` - - API: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems/{id}` +8. **AI Updates the User Story to Reflect Completion**: + - Tool: `update_work_item` + - API: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems/{id}` - The AI updates the work item’s state, such as setting it to “Done,” or adds a comment indicating completion, closing the workflow. #### Identified Gaps and Additional Tools + During mapping, a gap was identified for handling edge cases, particularly when the pipeline fails or conflicts arise. The following new tool was added: -- **Add Pull Request Comment**: - - Tool: `add_pull_request_comment` - - API: Azure DevOps Pull Request Threads API, likely `POST https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests/{pullRequestId}/threads` +- **Add Pull Request Comment**: + - Tool: `add_pull_request_comment` + - API: Azure DevOps Pull Request Threads API, likely `POST https://dev.azure.com/{organization}/{project}/_apis/Git/PullRequests/{pullRequestId}/threads` - This tool allows the AI to comment on the pull request, such as notifying about pipeline failures or conflicts, enhancing communication in the workflow. This addition ensures the server can handle scenarios where the pipeline fails, enabling the AI to comment on the pull request and potentially leave it for human intervention. #### Refined Tool List + The refined tool list, incorporating the new tool, is as follows: -| **Section** | **Tools** | -|----------------------------------|------------------------------------| -| Core Functionality | list_organizations, list_projects, list_repositories, get_project_details, get_repository_details | -| Repository Operations | create_or_update_file, push_changes, get_file_contents | +| **Section** | **Tools** | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Core Functionality | list_organizations, list_projects, list_repositories, get_project_details, get_repository_details | +| Repository Operations | create_or_update_file, push_changes, get_file_contents | | Branch and Pull Request Management | create_branch, create_pull_request, merge_pull_request, get_pull_request, list_pull_requests, add_pull_request_comment | -| Work Item Management | create_work_item, update_work_item, list_work_items, get_work_item, add_work_item_comment | -| Pipeline Interactions | trigger_pipeline, get_pipeline_status, list_pipelines | -| Search and Query | search_code, search_work_items, search_wiki | +| Work Item Management | create_work_item, update_work_item, list_work_items, get_work_item, add_work_item_comment | +| Pipeline Interactions | trigger_pipeline, get_pipeline_status, list_pipelines | +| Search and Query | search_code, search_work_items, search_wiki | This list ensures all use case steps are covered, with additional tools for edge case handling. #### Edge Cases and Considerations + Several edge cases were considered to validate feasibility: -1. **Pipeline Failure**: +1. **Pipeline Failure**: + - If `get_pipeline_status` indicates failure, the AI can use `add_pull_request_comment` to notify about the failure, potentially closing the pull request or leaving it for human review. This requires robust error handling in the tools. -2. **Pull Request Conflicts**: +2. **Pull Request Conflicts**: + - If `merge_pull_request` fails due to conflicts, the AI needs to handle the error, possibly by commenting via `add_pull_request_comment` and resolving conflicts manually, which is complex and may require human intervention. For now, the server assumes no conflicts or handles them externally. -3. **User Story Duplication**: +3. **User Story Duplication**: + - If a similar work item exists, the AI can use `list_work_items` or `search_work_items` to check, but for simplicity, the use case assumes unique user stories. This suggests potential for future refinement. -4. **Permissions Issues**: +4. **Permissions Issues**: - If API calls fail due to insufficient permissions (e.g., PAT lacks write access), error handling in each tool ensures the AI receives clear feedback, maintaining reliability. These considerations ensure the server is robust, with tools like `add_pull_request_comment` addressing communication needs in edge cases. #### Workflow Diagram Description + The workflow can be described as follows, aligning with the use case steps: -1. User inputs a user story request to the AI. -2. AI calls `create_work_item` to create the user story. -3. AI generates code internally. -4. AI calls `create_branch` to create a new branch, then `push_changes` to commit multiple files, or `create_or_update_file` for a single file. -5. AI calls `create_pull_request`, linking to the user story, triggering the pipeline (assumed automatic). -6. AI periodically calls `get_pipeline_status` to wait for completion, then, if passed, calls `merge_pull_request`. -7. AI calls `update_work_item` to mark the user story as completed. +1. User inputs a user story request to the AI. +2. AI calls `create_work_item` to create the user story. +3. AI generates code internally. +4. AI calls `create_branch` to create a new branch, then `push_changes` to commit multiple files, or `create_or_update_file` for a single file. +5. AI calls `create_pull_request`, linking to the user story, triggering the pipeline (assumed automatic). +6. AI periodically calls `get_pipeline_status` to wait for completion, then, if passed, calls `merge_pull_request`. +7. AI calls `update_work_item` to mark the user story as completed. 8. For edge cases, if the pipeline fails, AI calls `add_pull_request_comment` to notify on the pull request. This diagram ensures the sequence is clear, with tools supporting each step. #### Conclusion + The use case is feasible with the refined tool list, covering all steps from user story creation to pull request merging, with additional tools like `add_pull_request_comment` addressing edge cases. The server can support the AI in automating the workflow, though waiting for pipeline completion may require external handling by the AI, suggesting a potential area for future enhancement. This research validates the approach, equipping you to proceed with prototyping. #### Key Citations + - [Azure DevOps REST API Documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1) diff --git a/project-management/research/research-pt4.md b/project-management/research/research-pt4.md index abe374f..896f5b5 100644 --- a/project-management/research/research-pt4.md +++ b/project-management/research/research-pt4.md @@ -1,4 +1,5 @@ ### Key Points + - Research suggests the GitHub MCP server, part of the Model Context Protocol (MCP), uses clear patterns for building AI-integrated servers, which can guide the Azure DevOps server development. - It seems likely that the server uses the MCP Typescript SDK, defines tools with input parameters, and handles errors gracefully, offering a template for similar implementations. - The evidence leans toward the server using environment variables for configuration and ensuring safe operations, like maintaining Git history, which are crucial for the Azure DevOps server. @@ -8,9 +9,11 @@ ### Direct Answer #### Overview + The GitHub MCP server, found in the [modelcontextprotocol/servers repository](https://github.com/modelcontextprotocol/servers), shows how to build an AI-integrated server using the Model Context Protocol (MCP). It’s a great example for creating your Azure DevOps server, offering patterns you can follow. #### Key Patterns + - **Using Established Libraries**: The GitHub server uses Octokit to interact with GitHub APIs, similar to how you’ll use the `azure-devops-node-api` library for Azure DevOps. This makes API calls easier and more reliable. - **Clear Tool Definitions**: Each tool, like creating or updating files, has a name and defined inputs (e.g., path, content), ensuring the AI knows what to expect. You’ll do the same for tools like listing projects or creating work items. - **Error Handling**: It catches errors and returns them to the AI, so if something goes wrong, like a failed API call, the AI gets a clear message. This is important for your server too. @@ -18,6 +21,7 @@ The GitHub MCP server, found in the [modelcontextprotocol/servers repository](ht - **Safe Operations**: It avoids destructive actions, like force pushes, to keep Git history intact. Your server should do the same, ensuring changes are safe and traceable. #### Unexpected Detail + An interesting find is that the GitHub server creates the API client once and reuses it, improving performance by avoiding repeated setups. You can do the same with your Azure DevOps server to make it faster. For more details, check the [GitHub repository](https://github.com/modelcontextprotocol/servers/tree/main/src/github). @@ -29,9 +33,11 @@ For more details, check the [GitHub repository](https://github.com/modelcontextp This note provides an in-depth exploration of the GitHub MCP server, located at [GitHub - modelcontextprotocol/servers: Reference implementations of the Model Context Protocol (MCP) servers](https://github.com/modelcontextprotocol/servers), focusing on its implementation patterns for building an Azure DevOps reference server for the Model Context Protocol (MCP) as of 01:06 AM EST on Thursday, February 27, 2025. The analysis covers server structure, tool definitions, authentication, error handling, configuration, and security practices, ensuring a thorough understanding for applying these patterns to the Azure DevOps server. #### Introduction + The GitHub MCP server, part of the [modelcontextprotocol/servers repository](https://github.com/modelcontextprotocol/servers), is a reference implementation for integrating AI models, like those in Claude Desktop, with GitHub using the MCP protocol. Introduced on November 24, 2024, by Anthropic, MCP aims to standardize AI interactions with external tools, and the GitHub server exemplifies best practices for server development. This research analyzes its code, specifically in the `src/github` directory, to identify patterns for building a similar Azure DevOps server, focusing on structure, tool implementation, and operational safety. #### Server Structure and Setup + The GitHub server is defined in `src/github/index.ts`, using the MCP Typescript SDK’s `getMcpServer` function to create the server instance. The structure is as follows: ```ts @@ -50,6 +56,7 @@ This pattern establishes the server with a name and a collection of tools, align The server is designed to run locally, with a `package.json` file including a start script `"start": "ts-node index.ts"`, enabling execution via `npx -y @modelcontextprotocol/servergithub`. This pattern indicates that the Azure DevOps server should also support local deployment, configured via environment variables, as seen in the example configuration for Claude Desktop. #### Tool Definitions and Implementation + Each tool in the GitHub server is defined with a name, a handler function, and input parameters, ensuring clear communication with the MCP client. For example, the `create_or_update_file` tool is defined as: ```ts @@ -67,6 +74,7 @@ create_or_update_file: { ``` This pattern shows: + - Tools are async functions, handling API calls asynchronously, which aligns with the `azure-devops-node-api` library’s Promise-based operations. - Inputs are typed (e.g., `string`, `number`), crucial for MCP protocol compliance, ensuring the AI knows what parameters to provide. - The handler performs the operation, such as updating a file, using the Octokit library for GitHub API interactions. @@ -75,17 +83,18 @@ For the Azure DevOps server, this suggests defining tools like `list_projects`, A table of example tools from the GitHub server, with potential Azure DevOps equivalents, is provided below: -| **GitHub Server Tool** | **Description** | **Azure DevOps Equivalent** | **Potential Inputs** | -|------------------------------|----------------------------------------------|-----------------------------|-------------------------------| -| create_or_update_file | Creates or updates a file in the repo | create_or_update_file | path, content, commit_message, branch | -| push_files | Pushes multiple files, maintaining history | push_changes | files, commit_message, branch | -| search_repositories | Searches GitHub for repositories | search_code | query, project | -| create_issue | Creates a new GitHub issue | create_work_item | title, description, type | -| create_pull_request | Creates a pull request | create_pull_request | source_branch, target_branch | +| **GitHub Server Tool** | **Description** | **Azure DevOps Equivalent** | **Potential Inputs** | +| ---------------------- | ------------------------------------------ | --------------------------- | ------------------------------------- | +| create_or_update_file | Creates or updates a file in the repo | create_or_update_file | path, content, commit_message, branch | +| push_files | Pushes multiple files, maintaining history | push_changes | files, commit_message, branch | +| search_repositories | Searches GitHub for repositories | search_code | query, project | +| create_issue | Creates a new GitHub issue | create_work_item | title, description, type | +| create_pull_request | Creates a pull request | create_pull_request | source_branch, target_branch | This table highlights the mapping, ensuring the Azure DevOps server covers similar functionality with adjusted inputs for Azure DevOps’ structure (e.g., organization, project). #### Authentication and Configuration + The GitHub server uses environment variables for configuration, specifically `GITHUB_PERSONAL_ACCESS_TOKEN`, `GITHUB_REPOSITORY_OWNER`, and `GITHUB_REPOSITORY_NAME`, accessed via a `getOctokit` function that creates a memoized Octokit instance: ```ts @@ -100,6 +109,7 @@ This pattern ensures the API client is created once, improving performance by av The configuration is fixed to a default repository, with tools like `create_or_update_file` operating on that repository without taking it as a parameter. For the Azure DevOps server, this suggests configuring default organization, project, and optionally repository via environment variables (e.g., `AZURE_DEVOPS_ORG`, `AZURE_DEVOPS_PROJECT`), with flexibility for tools to accept parameters for broader scope (e.g., `list_repositories` at the project level). #### Error Handling and Safety + Error handling is a key pattern, with each tool wrapped in try-catch blocks to catch API call failures and return errors to the MCP client: ```ts @@ -115,7 +125,9 @@ try { This ensures the AI receives clear error messages, maintaining reliability. The GitHub server also avoids destructive actions, such as force pushes, by using API calls that create new commits (e.g., `createOrUpdateFileContents`), preserving Git history. For the Azure DevOps server, this suggests implementing similar error handling and ensuring operations like file updates (`PUT /Files/{path}`) create new commits, aligning with safe practices. #### Operational Patterns + The GitHub server includes tools for both specific operations (e.g., `create_or_update_file`) and general queries (e.g., `search_repositories`), suggesting a mix for the Azure DevOps server. For example: + - Specific: `create_work_item` for creating user stories. - General: `list_projects` for navigation, `search_code` for finding code across repositories. @@ -124,11 +136,14 @@ The server’s tools are designed to be self-explanatory, with names like `push_ An interesting detail is the memoization of the Octokit instance, ensuring efficiency by reusing the API client across tools. This pattern, seen in `getOctokit`, suggests creating a single `WebApi` instance for the Azure DevOps server, improving performance for frequent API calls. #### Community and Maintainability + The GitHub server’s code includes comments and clear structure, enhancing maintainability. For example, tool handlers have inline comments explaining API calls, suggesting the Azure DevOps server should document tools similarly for future reference. The repository’s active maintenance, part of the MCP ecosystem, ensures reliability, with no major issues reported as of February 27, 2025, indicating a stable foundation for patterns. #### Conclusion + The GitHub MCP server offers valuable patterns for building the Azure DevOps server, including using established libraries, defining clear tools with typed inputs, handling errors gracefully, configuring via environment variables, and ensuring safe operations. The memoized API client and mix of specific and general tools provide efficiency and flexibility, aligning with the goal of an AI-driven workflow from user story to pull request. This research equips you to proceed with prototyping, ensuring the Azure DevOps server is robust and MCP-compliant. #### Key Citations + - [GitHub - modelcontextprotocol/servers: Reference implementations of the Model Context Protocol (MCP) servers](https://github.com/modelcontextprotocol/servers) - [Model Context Protocol Typescript SDK](https://github.com/modelcontextprotocol/typescript-sdk) diff --git a/project-management/research/research-pt5.md b/project-management/research/research-pt5.md index a7bc064..2feee9d 100644 --- a/project-management/research/research-pt5.md +++ b/project-management/research/research-pt5.md @@ -1,4 +1,5 @@ ### Key Points + - Research suggests the `azure-devops-node-api` library is a suitable choice for interacting with Azure DevOps APIs in a Typescript MCP server, offering convenient wrappers for most operations. - It seems likely that the library supports core functionalities like listing projects and creating work items, but may require manual HTTP requests for listing organizations and search operations. - The evidence leans toward the library using Promises for asynchronous operations, requiring error handling and potentially manual pagination for large datasets. @@ -8,12 +9,15 @@ ### Direct Answer #### Overview + The `azure-devops-node-api` library is a Node.js tool that helps you interact with Azure DevOps services using Typescript, making it ideal for building your MCP server. It simplifies API calls by providing ready-made methods for tasks like managing repositories, work items, and pipelines. #### How It Works + To use it, first install it via npm and create a `WebApi` object with your Azure DevOps organization and a Personal Access Token (PAT) for authentication. Then, you can access different APIs, like `getCoreApi()` for projects or `getGitApi()` for repository operations. For example, to list projects, you’d use `coreApi.getProjects()`, and to create a work item, you’d call `witApi.createWorkItem()` with the right details. #### Limitations and Considerations + An interesting detail is that some operations, like listing all organizations or using the Search API, aren’t directly supported, so you’ll need to make raw HTTP requests for those using a library like Axios. Also, the library uses Promises, so you’ll need to handle asynchronous calls and errors, and manage pagination for large datasets manually if needed. For more details, check the [Azure DevOps Node API GitHub Repository](https://github.com/Microsoft/azure-devops-node-API) or the [Azure DevOps REST API Documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1). @@ -25,9 +29,11 @@ For more details, check the [Azure DevOps Node API GitHub Repository](https://gi This note provides an in-depth exploration of the `azure-devops-node-api` library, focusing on its suitability for building an Azure DevOps reference server for the Model Context Protocol (MCP) as of 01:01 AM EST on Thursday, February 27, 2025. The analysis covers installation, authentication, API interactions, error handling, pagination, and limitations, ensuring a thorough understanding for implementing the server’s tools. #### Introduction + The `azure-devops-node-api` library, hosted at [Azure Devops Node API GitHub Repository](https://github.com/Microsoft/azure-devops-node-API), is a Node.js library designed to interact with the Azure DevOps Services REST API. It provides a Typescript-friendly wrapper for making API calls, making it a suitable choice for building an MCP server that integrates with Azure DevOps for tasks like repository management, work item tracking, and pipeline automation. This research aims to understand its capabilities and identify any gaps for the server’s implementation. #### Installation and Initialization + To use the library, it must be installed via npm with the command `npm install azure-devops-node-api`. The library is compatible with Node.js environments, and its Typescript support aligns with the MCP server’s tech stack requirement. Initialization involves creating a `WebApi` object, typically using a Personal Access Token (PAT) for authentication, as shown below: ```ts @@ -42,19 +48,21 @@ const api = new WebApi(`https://dev.azure.com/${organization}`, pat); This approach ensures secure handling of sensitive credentials via environment variables, aligning with MCP server configuration practices. #### Authentication Support + The library supports PAT-based authentication, which is straightforward and mirrors the GitHub MCP server’s approach. The PAT is passed to the `WebApi` constructor, enabling access to Azure DevOps APIs. For other methods like OAuth or Azure Identity, the library can be extended, but PAT is sufficient for initial implementation. The documentation at [Azure Devops Node API Documentation](https://github.com/Microsoft/azure-devops-node-API/blob/master/README.md) provides examples, ensuring secure authentication setup. #### API Interactions + The library offers various API objects for different Azure DevOps services, accessed via methods like `getCoreApi()`, `getGitApi()`, `getWorkItemTrackingApi()`, and `getBuildApi()`. Each API object provides methods that wrap the corresponding REST API endpoints, simplifying interactions. Below is a table of common operations and their corresponding library methods: -| **Operation** | **API Object** | **Method Example** | **Corresponding REST Endpoint** | -|-----------------------------|-----------------------------|---------------------------------------------|-----------------------------------------------------| -| List Projects | CoreApi | `getProjects()` | `GET https://dev.azure.com/{org}/_apis/Projects` | -| List Repositories | GitApi | `getRepositories()` | `GET https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories` | -| Create Work Item | WorkItemTrackingApi | `createWorkItem()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Wit/WorkItems` | -| Trigger Build | BuildApi | `queueBuild()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Build/Builds` | -| Get File Contents | GitApi | `getItemContent()` | `GET https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories/{repoId}/Files/{path}` | -| Create Commit (File Update) | GitApi | `createCommit()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories/{repoId}/Commits` | +| **Operation** | **API Object** | **Method Example** | **Corresponding REST Endpoint** | +| --------------------------- | ------------------- | ------------------- | ------------------------------------------------------------------------------------- | +| List Projects | CoreApi | `getProjects()` | `GET https://dev.azure.com/{org}/_apis/Projects` | +| List Repositories | GitApi | `getRepositories()` | `GET https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories` | +| Create Work Item | WorkItemTrackingApi | `createWorkItem()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Wit/WorkItems` | +| Trigger Build | BuildApi | `queueBuild()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Build/Builds` | +| Get File Contents | GitApi | `getItemContent()` | `GET https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories/{repoId}/Files/{path}` | +| Create Commit (File Update) | GitApi | `createCommit()` | `POST https://dev.azure.com/{org}/{proj}/_apis/Git/Repositories/{repoId}/Commits` | For example, to list projects, the implementation would be: @@ -67,12 +75,18 @@ Similarly, to create a work item: ```ts const witApi = await api.getWorkItemTrackingApi(); -const workItem = await witApi.createWorkItem({}, { fields: { System_Title: 'New Task' } }, 'your_project', 'Task'); +const workItem = await witApi.createWorkItem( + {}, + { fields: { System_Title: 'New Task' } }, + 'your_project', + 'Task', +); ``` These methods handle the underlying HTTP requests, reducing the need for raw REST calls and ensuring consistency with Azure DevOps API versions. #### Asynchronous Operations and Error Handling + All library methods return Promises, requiring asynchronous handling with `await` or `.then()`. This aligns with MCP tool definitions, which expect async functions. Error handling is crucial, as API calls may fail due to authentication issues, rate limits, or invalid inputs. The library throws exceptions that must be caught, for example: ```ts @@ -87,28 +101,37 @@ try { This ensures the server can gracefully handle failures, maintaining reliability for AI-driven workflows. #### Pagination and Performance + Many API calls return paginated results, with the library supporting pagination via methods that return `WebApiTeamServicesResult`, which includes a `count`, `value` (array of results), and potentially a `nextLink` for fetching additional pages. For instance, listing work items might return: ```ts -const workItems = await witApi.getWorkItems('your_project', { top: 100, skip: 0 }); +const workItems = await witApi.getWorkItems('your_project', { + top: 100, + skip: 0, +}); ``` To fetch all pages, you’d need to implement a loop checking for `nextLink`, which the library does not handle automatically. For the MCP server, you might decide to return the first page with a note on pagination, balancing performance and completeness. This is particularly important for operations like `list_work_items`, where large datasets could impact response times. #### Limitations and Gaps + An interesting detail is that the library does not support all Azure DevOps APIs directly. Specifically: + - **Listing Organizations**: The endpoint `GET https://app.vssps.visualstudio.com/_apis/accounts` is not covered, as the `WebApi` object is tied to a specific organization. This requires raw HTTP requests using a library like Axios, impacting implementation for the `list_organizations` tool. - **Search API**: The [Search REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/search/?view=azure-devops-rest-7.1) (e.g., `POST https://almsearch.dev.azure.com/{org}/{proj}/_apis/search/codesearchresults`) is not supported, necessitating manual HTTP requests for tools like `search_code`, `search_work_items`, and `search_wiki`. This is manageable but adds complexity. These gaps are minor compared to the library’s coverage of core operations, and they can be addressed by combining the library with raw requests for unsupported endpoints. #### Community and Maintenance + The library, maintained by Microsoft, is actively updated, with the latest version (as of February 27, 2025) ensuring compatibility with Azure DevOps API version 7.1. Checking the GitHub repository’s issues and discussions reveals no major blockers, with community examples providing additional guidance for common use cases. #### Conclusion + The `azure-devops-node-api` library is a robust choice for building the Azure DevOps MCP server, offering convenient wrappers for most required operations like listing projects, creating work items, and managing repositories. It uses Promises for asynchronous calls, requires manual error handling, and supports pagination with some manual effort. However, it lacks support for listing organizations and the Search API, requiring raw HTTP requests for those tools. This research equips you to proceed with prototyping, ensuring alignment with your goal of an AI-driven workflow from user story to pull request. #### Key Citations + - [Azure Devops Node API GitHub Repository](https://github.com/Microsoft/azure-devops-node-API) - [Azure Devops Node API Documentation](https://github.com/Microsoft/azure-devops-node-API/blob/master/README.md) - [Azure Devops REST API Documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1) diff --git a/project-management/research/research-pt6.md b/project-management/research/research-pt6.md index e68ef06..32f7edc 100644 --- a/project-management/research/research-pt6.md +++ b/project-management/research/research-pt6.md @@ -7,9 +7,11 @@ Below is the updated version of the **Search and Query** section from Task #2, i This note reflects the completion of Task #2, with the **Search and Query** section revised based on your feedback. The rest of the sections (Core Functionality, Repository Operations, Work Item Management, Pipeline Interactions, and Azure DevOps-Specific Features) remain unchanged from my previous response, as they are unaffected by this update. #### Introduction to Azure DevOps REST APIs + The Azure DevOps REST APIs, documented at [Azure DevOps REST API | Microsoft Learn](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1), provide a comprehensive interface for interacting with Azure DevOps services. This research identifies key endpoints for an MCP server, with the [Search REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/search/?view=azure-devops-rest-7.1) powering the **Search and Query** section as of February 27, 2025. #### Core Functionality + - **Listing Organizations**: `GET https://app.vssps.visualstudio.com/_apis/accounts` - **Listing Projects**: `GET https://dev.azure.com/{organization}/_apis/Projects` - **Listing Repositories**: `GET https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories` @@ -17,11 +19,13 @@ The Azure DevOps REST APIs, documented at [Azure DevOps REST API | Microsoft Lea - **Getting Repository Details**: `GET https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}` #### Repository Operations + - **Create or Update File**: `PUT https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Files/{path}` (auto-commits) - **Push Changes**: `POST https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Commits` - **Get File Contents**: `GET https://dev.azure.com/{organization}/{project}/_apis/Git/Repositories/{repositoryId}/Files/{path}` #### Work Item Management + - **Create Work Item**: `POST https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems` - **Update Work Item**: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems/{id}` - **List Work Items**: `GET https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems` @@ -29,55 +33,65 @@ The Azure DevOps REST APIs, documented at [Azure DevOps REST API | Microsoft Lea - **Add Work Item Comment**: `POST https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems/{id}/comments` #### Pipeline Interactions + - **Trigger Pipeline**: `POST https://dev.azure.com/{organization}/{project}/_apis/Build/Builds` - **Get Pipeline Status**: `GET https://dev.azure.com/{organization}/{project}/_apis/Build/Builds/{buildId}` - **List Pipelines**: `GET https://dev.azure.com/{organization}/{project}/_apis/Build/Definitions` #### Search and Query (Revised with New Tool Names) + The Azure DevOps Search REST API provides specialized endpoints for searching across Azure DevOps entities, making it the ideal choice for the **Search and Query** section of your MCP server. Based on your feedback, "search repositories" is renamed to "search code" for clarity, and "search commits" is replaced with "search wiki" to leverage Azure DevOps’ wiki search capabilities. The revised tools and endpoints are: -- **Search Code**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/codesearchresults` searches code and repository metadata across a project. - - **Request Body**: Includes `searchText` (e.g., “login function”), `filters` (e.g., `RepositoryFilters:MainApp`, `ProjectFilters:AppDev`), and `$top` for pagination. - - **Use**: AI locates specific code snippets or files (e.g., “login.js” in the “MainApp” repo) to inform development tasks. +- **Search Code**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/codesearchresults` searches code and repository metadata across a project. + + - **Request Body**: Includes `searchText` (e.g., “login function”), `filters` (e.g., `RepositoryFilters:MainApp`, `ProjectFilters:AppDev`), and `$top` for pagination. + - **Use**: AI locates specific code snippets or files (e.g., “login.js” in the “MainApp” repo) to inform development tasks. - **Rationale**: Renaming from "search repositories" emphasizes the focus on code content rather than just repository listings, aligning with the API’s strength in code-level search. -- **Search Work Items**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/workitemsearchresults` searches work items based on text and filters. - - **Request Body**: Includes `searchText` (e.g., “login feature”), `filters` (e.g., `WorkItemType:User Story`), and `$top`. +- **Search Work Items**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/workitemsearchresults` searches work items based on text and filters. + + - **Request Body**: Includes `searchText` (e.g., “login feature”), `filters` (e.g., `WorkItemType:User Story`), and `$top`. - **Use**: AI finds related work items (e.g., existing user stories about “login”) to avoid duplication or provide context. -- **Search Wiki**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/wikisearchresults` searches wiki content within a project. - - **Request Body**: Includes `searchText` (e.g., “authentication setup”), `filters` (e.g., `WikiFilters:ProjectWiki`), and `$top`. - - **Use**: AI retrieves wiki documentation (e.g., setup guides or standards) to guide code generation or task planning. +- **Search Wiki**: The endpoint `POST https://almsearch.dev.azure.com/{organization}/{project}/_apis/search/wikisearchresults` searches wiki content within a project. + - **Request Body**: Includes `searchText` (e.g., “authentication setup”), `filters` (e.g., `WikiFilters:ProjectWiki`), and `$top`. + - **Use**: AI retrieves wiki documentation (e.g., setup guides or standards) to guide code generation or task planning. - **Rationale**: Replacing "search commits" with "search wiki" taps into Azure DevOps’ wiki feature, offering valuable knowledge for AI-driven workflows instead of commit-specific searches. -**Additional Notes**: -- Pagination is managed with `$top` (up to 1000 results) and `$skip`, requiring server-side handling for large result sets. -- Permissions like “Read” access to repos, work items, and wikis are required, scoped via authentication settings. +**Additional Notes**: + +- Pagination is managed with `$top` (up to 1000 results) and `$skip`, requiring server-side handling for large result sets. +- Permissions like “Read” access to repos, work items, and wikis are required, scoped via authentication settings. - The Search API’s POST-based approach with JSON payloads offers richer filtering than GET-based alternatives, enhancing flexibility. #### Azure DevOps-Specific Features + - **Update Board**: `PATCH https://dev.azure.com/{organization}/{project}/_apis/Wit/WorkItems/{id}` (via field updates) - **Create Test Plan**: `POST https://dev.azure.com/{organization}/{project}/_apis/test/Plans` - **Publish Artifact**: Indirect via `POST https://dev.azure.com/{organization}/{project}/_apis/Build/Builds` #### Technical Considerations -- **Pagination**: `$top` and `$skip` for Search API; similar for other endpoints. -- **Rate Limits**: Subscription-based, needing retry logic for 429 errors. + +- **Pagination**: `$top` and `$skip` for Search API; similar for other endpoints. +- **Rate Limits**: Subscription-based, needing retry logic for 429 errors. - **Error Handling**: Critical for operations like code updates or pipeline triggers. #### Conclusion + The Azure DevOps REST APIs, with the Search REST API powering **Search and Query**, provide a strong foundation for your MCP server. The revised tools—`search_code`, `search_work_items`, and `search_wiki`—leverage the Search API’s capabilities, offering precise and efficient searches across code, work items, and wikis. This update enhances the server’s ability to support AI-driven workflows, maintaining alignment with your end-to-end use case from user story to pull request. #### Key Citations + - [Azure DevOps REST API Documentation](https://learn.microsoft.com/en-us/rest/api/azure/devops/?view=azure-devops-rest-7.1) - [Azure DevOps Search REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/search/?view=azure-devops-rest-7.1) --- ### Impact on Your MCP Server -- **Tool Renaming**: - - `search_code` (formerly `search_repos`) focuses on finding code snippets or files, not just listing repos (covered by `list_repositories` in Core Functionality). - - `search_wiki` (replacing `search_commits`) shifts the focus to wiki content, providing AI with documentation access instead of commit history (still accessible via `GET /Commits` if needed later). + +- **Tool Renaming**: + - `search_code` (formerly `search_repos`) focuses on finding code snippets or files, not just listing repos (covered by `list_repositories` in Core Functionality). + - `search_wiki` (replacing `search_commits`) shifts the focus to wiki content, providing AI with documentation access instead of commit history (still accessible via `GET /Commits` if needed later). - **Implementation**: These tools use POST requests with JSON bodies, requiring a slightly different approach in Typescript compared to GET-based endpoints, but they offer richer search capabilities. This refinement keeps your server aligned with Azure DevOps’ strengths. Shall I proceed with another research task (e.g., Task #1 or #3), or would you like me to draft a sample implementation for these Search tools using the MCP SDK? diff --git a/src/test-azure-devops-api.ts b/project-management/spikes/test-azure-devops-api.ts similarity index 84% rename from src/test-azure-devops-api.ts rename to project-management/spikes/test-azure-devops-api.ts index 35d4ffa..cdcadb7 100644 --- a/src/test-azure-devops-api.ts +++ b/project-management/spikes/test-azure-devops-api.ts @@ -10,43 +10,47 @@ dotenv.config(); async function testAzureDevOpsApi() { console.log('Testing Azure DevOps API...'); - + // Check if the PAT is set const pat = process.env.AZURE_DEVOPS_PAT; if (!pat) { - console.log('AZURE_DEVOPS_PAT environment variable is not set. Skipping API test.'); + console.log( + 'AZURE_DEVOPS_PAT environment variable is not set. Skipping API test.', + ); return; } - + // Check if the organization is set const org = process.env.AZURE_DEVOPS_ORG; if (!org) { - console.log('AZURE_DEVOPS_ORG environment variable is not set. Skipping API test.'); + console.log( + 'AZURE_DEVOPS_ORG environment variable is not set. Skipping API test.', + ); return; } - + try { // Create a connection to Azure DevOps const orgUrl = `https://dev.azure.com/${org}`; const authHandler = getPersonalAccessTokenHandler(pat); const webApi = new WebApi(orgUrl, authHandler); - + // Get the Core API const coreApi = await webApi.getCoreApi(); - + // List projects console.log(`Listing projects for organization: ${org}`); const projects = await coreApi.getProjects(); - + if (projects && projects.length > 0) { console.log(`Found ${projects.length} projects:`); - projects.forEach(project => { + projects.forEach((project) => { console.log(`- ${project.name} (${project.id})`); }); } else { console.log('No projects found.'); } - + console.log('Azure DevOps API test completed successfully.'); } catch (error) { console.error('Error testing Azure DevOps API:', error); @@ -54,4 +58,4 @@ async function testAzureDevOpsApi() { } // Run the test -testAzureDevOpsApi(); \ No newline at end of file +testAzureDevOpsApi(); diff --git a/src/test-azure-identity.ts b/project-management/spikes/test-azure-identity.ts similarity index 82% rename from src/test-azure-identity.ts rename to project-management/spikes/test-azure-identity.ts index 435d461..5f8e692 100644 --- a/src/test-azure-identity.ts +++ b/project-management/spikes/test-azure-identity.ts @@ -1,4 +1,8 @@ -import { DefaultAzureCredential, AzureCliCredential, ChainedTokenCredential } from '@azure/identity'; +import { + DefaultAzureCredential, + AzureCliCredential, + ChainedTokenCredential, +} from '@azure/identity'; import { WebApi } from 'azure-devops-node-api'; import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearertoken'; @@ -8,24 +12,23 @@ import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearerto async function testAzureIdentity() { try { console.log('Testing Azure Identity authentication...'); - + // Test DefaultAzureCredential console.log('Testing DefaultAzureCredential...'); const defaultCredential = new DefaultAzureCredential(); await testCredential('DefaultAzureCredential', defaultCredential); - + // Test AzureCliCredential console.log('Testing AzureCliCredential...'); const cliCredential = new AzureCliCredential(); await testCredential('AzureCliCredential', cliCredential); - + // Test ChainedTokenCredential with AzureCliCredential as fallback console.log('Testing ChainedTokenCredential...'); const chainedCredential = new ChainedTokenCredential( - new AzureCliCredential() + new AzureCliCredential(), ); await testCredential('ChainedTokenCredential', chainedCredential); - } catch (error) { console.error('Error testing Azure Identity:', error); } @@ -33,7 +36,7 @@ async function testAzureIdentity() { /** * Test a specific credential with Azure DevOps - * + * * @param name The name of the credential * @param credential The credential to test */ @@ -42,10 +45,12 @@ async function testCredential(name: string, credential: any) { // Azure DevOps requires the 499b84ac-1321-427f-aa17-267ca6975798/.default scope // This is the Azure DevOps resource ID const azureDevOpsResourceId = '499b84ac-1321-427f-aa17-267ca6975798'; - const token = await credential.getToken(`${azureDevOpsResourceId}/.default`); - + const token = await credential.getToken( + `${azureDevOpsResourceId}/.default`, + ); + console.log(`${name} token acquired:`, token ? 'Success' : 'Failed'); - + if (token) { // Test the token with Azure DevOps const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || ''; @@ -53,17 +58,19 @@ async function testCredential(name: string, credential: any) { console.error('AZURE_DEVOPS_ORG_URL environment variable is required'); return; } - + console.log(`Testing ${name} with Azure DevOps API...`); const authHandler = new BearerCredentialHandler(token.token); const connection = new WebApi(orgUrl, authHandler); - + // Test the connection const coreApi = await connection.getCoreApi(); const projects = await coreApi.getProjects(); - - console.log(`${name} connection successful. Found ${projects.length} projects.`); - console.log('Projects:', projects.map(p => p.name).join(', ')); + + console.log( + `${name} connection successful. Found ${projects.length} projects.`, + ); + console.log('Projects:', projects.map((p) => p.name).join(', ')); } } catch (error) { console.error(`Error testing ${name}:`, error); @@ -71,4 +78,4 @@ async function testCredential(name: string, credential: any) { } // Run the test -testAzureIdentity().catch(console.error); \ No newline at end of file +testAzureIdentity().catch(console.error); diff --git a/src/test-list-orgs.ts b/project-management/spikes/test-list-orgs.ts similarity index 85% rename from src/test-list-orgs.ts rename to project-management/spikes/test-list-orgs.ts index 5f27630..525f4d0 100644 --- a/src/test-list-orgs.ts +++ b/project-management/spikes/test-list-orgs.ts @@ -7,19 +7,18 @@ import { listOrganizations } from './operations/organizations'; async function testListOrganizations() { try { console.log('Testing listing organizations with Azure Identity...'); - + const result = await listOrganizations({ organizationUrl: 'https://dev.azure.com/unused', // This URL isn't actually used for listing organizations - authMethod: AuthenticationMethod.AzureIdentity + authMethod: AuthenticationMethod.AzureIdentity, }); - + console.log('Organizations found:', result.length); console.log('Organizations:', result); - } catch (error) { console.error('Error listing organizations:', error); } } // Run the test -testListOrganizations().catch(console.error); \ No newline at end of file +testListOrganizations().catch(console.error); diff --git a/project-management/task-management/doing.md b/project-management/task-management/doing.md index a567c80..3651e12 100644 --- a/project-management/task-management/doing.md +++ b/project-management/task-management/doing.md @@ -1 +1,3 @@ ## Current Tasks In Progress + +No tasks currently in progress. All tasks have been completed and moved to done.md. diff --git a/project-management/task-management/done.md b/project-management/task-management/done.md index 0643c71..50c63ec 100644 --- a/project-management/task-management/done.md +++ b/project-management/task-management/done.md @@ -1,5 +1,7 @@ ## Completed Tasks + - [x] **Task 2.6**: Implement `list_work_items` handler with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Description**: Implement the `list_work_items` tool for the Azure DevOps MCP server using WebApi with tests. @@ -12,6 +14,7 @@ - Aligned the implementation with the established project patterns - [x] **Task 1.8**: Document core navigation tools (usage, parameters) + - **Role**: Technical Writer - **Phase**: Completion - **Description**: Create comprehensive documentation for the core navigation tools (list_organizations, list_projects, list_repositories) @@ -24,6 +27,7 @@ - Each tool documentation includes detailed parameter descriptions, response formats, error handling, and usage examples - [x] **Task 1.6**: Implement `list_repositories` using WebApi with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Description**: Implement the `list_repositories` tool for the Azure DevOps MCP server using WebApi with tests. @@ -35,6 +39,7 @@ - Integrated with the MCP server interface - [x] **Task 1.4**: Implement `list_projects` using WebApi with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Description**: Implement the `list_projects` tool for the Azure DevOps MCP server using WebApi with tests. @@ -46,6 +51,7 @@ - Verified integration with the Azure DevOps WebApi - [x] **Task A.7**: Update authentication documentation + - **Role**: Technical Writer - **Phase**: Completion - **Description**: Document new authentication methods, add examples for all supported auth methods, and update troubleshooting guide. @@ -58,45 +64,42 @@ - Created a configuration reference table for environment variables - Added best practices for authentication security -- [x] **Task A.1**: Research and document Azure Identity implementation options +- [x] **Task A.1**: Research and document Azure Identity implementation options + - **Role**: Technical Architect - **Phase**: Research - **Description**: Research DefaultAzureCredential and related Azure Identity SDKs, determine ideal authentication flow using Azure CLI credentials, and document findings and implementation approach. - **Notes**: + - **Azure Identity SDK Overview**: - The `@azure/identity` package provides various credential types for authenticating with Azure services - Key credential types include `DefaultAzureCredential`, `AzureCliCredential`, `ChainedTokenCredential`, and others - These credentials can be used with Azure DevOps by obtaining a bearer token and using it with the `BearerCredentialHandler` - - **DefaultAzureCredential**: - Provides a simplified authentication experience by trying multiple credential types in sequence - Attempts to authenticate using environment variables, managed identity, Azure CLI, Visual Studio Code, and other methods - Ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes - For Azure DevOps, it requires the resource ID `499b84ac-1321-427f-aa17-267ca6975798` to obtain the correct token scope - - **AzureCliCredential**: - Authenticates using the Azure CLI's logged-in account - Requires the Azure CLI to be installed and the user to be logged in (`az login`) - Good for local development scenarios where developers are already using the Azure CLI - Can be used as a fallback mechanism in a `ChainedTokenCredential` - - **Implementation Approach**: - Create an abstraction layer for authentication that supports both PAT and Azure Identity methods - Implement a factory pattern to create the appropriate credential based on configuration - Use `DefaultAzureCredential` as the primary Azure Identity method with `AzureCliCredential` as a fallback - Update the configuration to support specifying the authentication method (PAT, AAD, DefaultAzureCredential) - Implement proper error handling and logging for authentication failures - - **Integration with Azure DevOps Node API**: - The Azure DevOps Node API supports bearer token authentication via the `BearerCredentialHandler` class - Tokens obtained from Azure Identity can be used with this handler to authenticate API requests - Example: `const authHandler = new BearerCredentialHandler(token.token); const connection = new WebApi(orgUrl, authHandler);` - - **Configuration Requirements**: - For PAT: `AZURE_DEVOPS_PAT` and `AZURE_DEVOPS_ORG_URL` - For DefaultAzureCredential: `AZURE_DEVOPS_ORG_URL` and potentially `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` depending on the environment - New configuration option: `AZURE_DEVOPS_AUTH_METHOD` to specify which authentication method to use - + - **Sub-tasks**: - [x] Research DefaultAzureCredential and related Azure Identity SDKs - [x] Determine ideal authentication flow using Azure CLI credentials @@ -105,6 +108,7 @@ - **Pull Request**: [#12](https://github.com/Tiberriver256/azure-devops-mcp/pull/12) - [x] **Task 0.11**: Document project setup and authentication (README) + - **Role**: Technical Writer - **Phase**: Research - **Description**: Create comprehensive documentation for setting up the Azure DevOps MCP server, including authentication methods (PAT and AAD), setup instructions, and examples. @@ -117,50 +121,48 @@ - **Completed**: March 15, 2024 - [x] **Task 0.7**: Fix MCP Server Implementation + - **Role**: Full-Stack Developer - **Phase**: Research - **Description**: Fix the Azure DevOps MCP server implementation to correctly use the MCP SDK. Currently, the server is not properly implementing the MCP protocol, causing connection errors when trying to use it with the inspector. - **Notes**: + - **How we discovered the issue**: - Attempted to connect to our server with the MCP inspector - Received error: "TypeError: transport.onMessage is not a function at AzureDevOpsServer.connect" - Root cause: We're incorrectly implementing the MCP server protocol - - **What we can learn from the GitHub implementation**: - GitHub implementation in `project-management/reference/mcp-server/src/github/index.ts` shows the correct pattern - They directly use the `Server` class from the SDK rather than creating a custom class - They register handlers using `server.setRequestHandler()` for specific request schemas - They have a clear pattern for tool implementation and error handling - - **Key differences in implementation**: - GitHub uses `import { Server } from "@modelcontextprotocol/sdk/server/index.js"` - They register request handlers with `server.setRequestHandler(ListToolsRequestSchema, async () => {...})` - Tool implementations follow a switch/case pattern based on the tool name - They connect to the transport using `await server.connect(transport)` - Our implementation attempts to handle transport messages directly which is incorrect - - **Learning resources**: + - Reference implementation in `project-management/reference/mcp-server/` - MCP SDK documentation - The specific schema structure shown in the GitHub reference - **Specific Changes Required**: + 1. Server Class Changes: - Replace our custom `McpServer` usage with `Server` from SDK - Remove our custom `connect()` method implementation - Move Azure DevOps connection logic to tool handlers - 2. Tool Registration Changes: - Replace our custom `tool()` method with proper request handlers - Implement `ListToolsRequestSchema` handler to declare available tools - Implement `CallToolRequestSchema` handler with switch/case for tool execution - Move tool implementations into separate modules like GitHub's pattern - 3. Transport Handling: - Remove custom transport handling code - Let SDK handle transport via `server.connect(transport)` - Ensure proper error handling and response formatting - 4. Configuration: - Keep Azure DevOps config but integrate it with SDK server config - Move tool-specific config into tool modules @@ -175,8 +177,8 @@ - [x] Test the implementation with the MCP inspector - [x] Ensure all existing unit tests still pass - - [x] **Task 0.6**: Implement basic server structure following TDD (Express setup with tests) + - **Role**: Full-Stack Developer - **Phase**: Completion - **Notes**: @@ -194,6 +196,7 @@ - [x] Document the server structure setup in README.md - [x] **Task 0.3**: Configure CI/CD pipeline with a basic build + - **Role**: Full-Stack Developer - **Phase**: Implementation - **Notes**: @@ -206,6 +209,7 @@ - [x] Added a test step to run the unit tests. - [x] **Task 0.4**: Set up development environment (Node.js, Typescript, VS Code) + - **Role**: Full-Stack Developer - **Phase**: Research - **Notes**: @@ -220,6 +224,7 @@ - [x] Verified the development environment works correctly - [x] **Task 0.5**: Install project dependencies (e.g., azure-devops-node-api, @modelcontextprotocol/sdk) + - **Role**: Full-Stack Developer - **Phase**: Implementation - **Notes**: @@ -237,6 +242,7 @@ - [x] Updated package.json with appropriate versions and scripts - [x] **Task 0.9**: Implement PAT-based authentication handler with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Notes**: @@ -264,6 +270,7 @@ - [x] Add troubleshooting guide - [x] **Task 0.10**: Fix integration tests in CI environment + - **Role**: Full-Stack Developer - **Phase**: Completed - **Description**: Configure integration tests to work in CI environment by properly handling credentials and environment setup @@ -280,6 +287,7 @@ - ✅ Verify tests pass in CI environment - [x] **Task 1.2**: Implement `list_organizations` using Axios with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Description**: Implement the list_organizations tool which allows users to retrieve all Azure DevOps organizations accessible to the authenticated user. This tool will use Axios for direct API calls rather than the WebApi client. @@ -302,6 +310,7 @@ - **Completed**: March 15, 2024 - [x] **Task 2.2**: Implement `create_work_item` handler with tests + - **Role**: Full-Stack Developer - **Phase**: Completion - **Description**: Implement the `create_work_item` tool for the Azure DevOps MCP server with tests. @@ -310,7 +319,7 @@ - Created the `createWorkItem` function with comprehensive error handling - Registered the tool in the server's tool registry - Added unit tests achieving 97.53% statement coverage for workitems.ts - - Improved overall project test coverage to 93.97% + - Improved overall project test coverage to 93.97% - Created detailed documentation in docs/tools/work-items.md - Updated the main documentation index to include work item tools - **Completed**: March 15, 2024 @@ -334,11 +343,13 @@ - **Pull Request**: [#18](https://github.com/Tiberriver256/azure-devops-mcp/pull/18) ### Task 0.1: Initialize Git repository and set up branch policies + **Role**: Full-Stack Developer **Completed**: ✓ **Phase**: Research #### Notes + - Need to initialize a new Git repository - Set up branch protection rules - Configure main branch as protected @@ -346,6 +357,7 @@ - Enable status checks #### Sub-tasks + 1. [x] Initialize Git repository 2. [x] Create initial project structure 3. [x] Set up branch protection for main branch @@ -354,6 +366,7 @@ 6. [x] Add .gitignore file ### Task A.2: Create authentication abstraction layer + - **Role**: Full-Stack Developer - **Phase**: Completed - Design interface to abstract authentication methods (PAT, AAD, DefaultAzureCredential) @@ -361,9 +374,153 @@ - Add unit tests #### Notes and Sub-tasks: + - Created auth-factory.ts to implement the authentication factory pattern - Created client-factory.ts to provide a client interface using the authentication factory - Added support for PAT, DefaultAzureCredential, and AzureCliCredential authentication methods - Updated server.ts and index.ts to use the new authentication abstraction - Added unit tests for the authentication factory and client factory - Updated .env.example to include the new authentication methods + +- [x] **Task 1.0**: Reorganize repository structure around "Screaming Architecture" and "Vertical Slices" + + - **Role**: Software Architect, Full-Stack Developer + - **Phase**: Implementation + - **Description**: Refactor directory structure to emphasize business domains rather than technical layers, group related functionality into feature-based modules, ensure each vertical slice contains all necessary components, and update imports and references across the codebase. + - **Completion Date**: 2023-06-13 + - **Pull Request**: [#20](https://github.com/Tiberriver256/azure-devops-mcp/pull/20) + - **Notes**: + + - **Current Architecture Analysis**: + - The codebase currently follows a mostly layer-based architecture with some domain grouping + - Main directories: src/api, src/auth, src/common, src/config, src/operations, src/tools, src/types, src/utils + - Operations are somewhat grouped by domain (workitems, organizations, projects, repositories) + - Tests follow a similar structure to the source code + - Client initialization and auth logic is separated from domain operations + - **Screaming Architecture Understanding**: + + - Focuses on making the architecture "scream" about the business domain, not technical details + - Names directories/components after business concepts, not technical layers + - Makes the application purpose clear at a glance through its structure + + - **Vertical Slice Architecture Understanding**: + - Organizes code by features rather than layers + - Each slice contains all components needed for a single feature (API, business logic, data access) + - Allows for independent development and changes to features + - High cohesion within a slice, loose coupling between slices + - Easier to navigate and understand when working on a specific feature + - **Feature-Sliced Design Understanding**: + - Formalized architectural methodology with three key concepts: + - Layers: Top-level folders that define the application structure + - Slices: Domain divisions within layers + - Segments: Technical divisions within slices + - Clear import rules: A module can only import other slices when they are located on layers strictly below + - Promotes loose coupling and high cohesion like Vertical Slice Architecture + - **Proposed New Structure**: + - Group code by Azure DevOps domain concepts (Work Items, Repositories, Projects, Organizations, etc.) + - Each domain folder contains all functionality related to that domain + - Inside each domain folder, implement vertical slices for each operation + - Move shared code to a dedicated location + - Colocate unit tests with the implementation files + - **Benefits of New Structure**: + + - Makes the purpose of the application clear through the directory structure + - Easier to find and modify specific features + - Isolates changes to a specific domain/feature + - Improves developer experience by keeping related code together + - Colocation of tests with implementation makes test coverage more obvious + + - **Progress So Far**: + + - Created the new directory structure with `features` and `shared` top-level folders + - Implemented the work-items feature with vertical slices for: + - list-work-items + - get-work-item + - create-work-item + - update-work-item + - Implemented the projects feature with vertical slices for: + - get-project + - list-projects + - Implemented the repositories feature with vertical slices for: + - get-repository + - list-repositories + - Implemented the organizations feature with vertical slice for: + - list-organizations + - Updated the server.ts file to use the new implementations + - Fixed Jest configuration to recognize tests in the src directory + - Moved some shared code (errors, config, types) to the shared directory + - Moved exploration test files from src root to project-management/spikes + - Successfully ran tests for the work-items, projects, repositories, and organizations features + - Fixed error handling in the organizations feature to properly throw AzureDevOpsAuthenticationError for profile API errors + - Fixed import paths in the server-list-work-items.test.ts file + - Removed unused imports from shared modules + - Fixed mocks and references in tests/unit/server-coverage.test.ts to match the new feature structure + - Fixed import paths in the integration test (tests/integration/server.test.ts) + - Achieved passing tests for 30 out of 31 test suites (only integration test failing due to missing valid credentials) + - Improved code coverage close to threshold requirements (78.38% statements, 78.29% lines) + - Deleted obsolete directories and files after confirming migration was complete: + - Removed old layer-based directories: api/, auth/, common/, config/, types/, tools/, utils/ + - Removed operations/ directory after verifying all functionality was migrated to features/ + - Cleaned up outdated imports in remaining test files + - Successfully moved all unit tests to be co-located with implementation files: + - Moved server-list-work-items.test.ts to src/features/work-items/list-work-items/server.test.ts + - Moved all feature-based tests to their corresponding feature directories + - Moved all shared module tests to their respective locations + - Fixed import paths in some of the moved test files + - Removed the obsolete tests/unit directory + - Feature tests are all passing with good coverage: + - All work-items feature tests pass: 100% coverage + - All projects feature tests pass: 100% coverage + - All repositories feature tests pass: 100% coverage + - All organizations feature tests pass: 97% coverage (only missing one branch case) + - Reorganized test structure to follow co-location pattern: + - Created client-factory.test.ts from server-client.test.ts + - Enhanced auth-factory.test.ts with server-auth.test.ts tests + - Created server.test.ts from server-coverage.test.ts + + - **Next Steps**: + - Fix remaining test issues: + - Fix resetTime/resetAt property issues in error tests + - Fix integration tests with valid credentials + - Improve coverage for shared modules (auth, api, errors) + - Ensure all tests pass after refactoring + + - **Sub-tasks**: + - [x] Research Screaming Architecture and Vertical Slices patterns + - [x] Analyze current codebase organization + - [x] Design new directory structure + - [x] Create base directory structure + - [x] Implement list-work-items feature as example + - [x] Refactor remaining work-items features + - [x] Refactor projects features + - [x] Refactor repositories features + - [x] Refactor organizations features + - [x] Update imports and references in server.ts + - [x] Fix error handling in the organizations feature + - [x] Fix some import paths in shared modules + - [x] Fix coverage tests + - [x] Delete unused files and empty directories + - [x] Move unit tests to be co-located with implementation files + - [x] Move work-items/list-work-items server tests + - [x] Move work-items feature tests + - [x] Move repositories feature tests + - [x] Move projects and organizations tests + - [x] Move API/auth tests + - [x] Move server and index tests + - [x] Update import paths in moved test files + - [x] Fixed list-work-items server test imports + - [x] Fixed list-work-items operations test imports + - [x] Removed duplicate test files + - [x] Deleted tests/unit directory + - [x] Fixed workitems-coverage.test.ts imports + - [x] Fixed server-coverage.test.ts imports + - [x] Fixed update-work-item/server.test.ts imports + - [x] Fixed create-work-item/server.test.ts imports + - [x] Fix remaining test issues: + - [x] Update import paths in the remaining test files: + - [x] Update auth related test files + - [x] Update server-client.test.ts and api-errors.test.ts + - [x] Update feature tests/operations.test.ts files + - [x] Update server.test.ts + - [ ] Fix resetTime/resetAt property issues in error tests + - [ ] Fix integration tests with valid credentials diff --git a/project-management/task-management/todo.md b/project-management/task-management/todo.md index 70bd5be..90ad77d 100644 --- a/project-management/task-management/todo.md +++ b/project-management/task-management/todo.md @@ -1,11 +1,18 @@ ## Azure DevOps MCP Server Project TODO List (Granular Daily Tasks) ### Task Structure + - Each task is designed to take **one day or less** to complete. - All tasks follow the TDD workflow naturally: writing failing tests, code until they pass, and refactoring are part of one continuous process. - Documentation is still handled as a separate step for clarity. - Tasks are prefixed with `[ ]` for tracking completion. +### Repository Restructuring + +### DevOps + +- [ ] **Task 1.0**: Fix all errors shown by `npm run lint` and add it to .github/workflows/main.yml so they don't build up again. + ### Authentication Enhancements - [ ] **Task 2.8**: Implement `get_work_item` handler with tests diff --git a/setup_env.sh b/setup_env.sh index 2228228..d6145f7 100755 --- a/setup_env.sh +++ b/setup_env.sh @@ -246,147 +246,6 @@ if [[ "$set_default_project" = "y" || "$set_default_project" = "Y" ]]; then fi fi -# Create PAT -echo -e "\n${YELLOW}Step 4: Creating a Personal Access Token (PAT)...${NC}" - -# Two methods to create PAT: REST API or browser -echo "Would you like to create a PAT using:" -echo "1) REST API (automated, requires appropriate permissions)" -echo "2) Web browser (manual, more reliable)" -read -p "Select option (1/2): " pat_method - -pat_token="" - -if [ "$pat_method" = "1" ]; then - # Create temporary JSON file for PAT creation - temp_pat_json=$(mktemp) - # Calculate date 7 days from now in ISO format - # Use a more compatible approach for date calculation - expiry_date="" - - # Try macOS style date command - if date -v+7d >/dev/null 2>&1; then - expiry_date=$(date -u -v+7d "+%Y-%m-%dT%H:%M:%S.000Z") - # Try GNU date style - elif date -d "+7 days" >/dev/null 2>&1; then - expiry_date=$(date -u -d "+7 days" "+%Y-%m-%dT%H:%M:%S.000Z") - # Fallback to a basic approach using current date + 7 days - else - # Get current date components - year=$(date -u +"%Y") - month=$(date -u +"%m") - day=$(date -u +"%d") - time=$(date -u +"%H:%M:%S") - - # Add 7 days (very simplistic, doesn't account for month/year boundaries) - day=$((day + 7)) - # Simple adjustment for month end (not perfect but better than nothing) - if [ "$month" = "04" ] || [ "$month" = "06" ] || [ "$month" = "09" ] || [ "$month" = "11" ]; then - if [ "$day" -gt 30 ]; then - day=$((day - 30)) - month=$((month + 1)) - fi - elif [ "$month" = "02" ]; then - if [ "$day" -gt 28 ]; then - day=$((day - 28)) - month=$((month + 1)) - fi - else - if [ "$day" -gt 31 ]; then - day=$((day - 31)) - month=$((month + 1)) - if [ "$month" -gt 12 ]; then - month=1 - year=$((year + 1)) - fi - fi - fi - - # Format with leading zeros - if [ "$day" -lt 10 ]; then day="0$day"; fi - if [ "$month" -lt 10 ]; then month="0$month"; fi - - expiry_date="${year}-${month}-${day}T${time}.000Z" - fi - - echo "Setting PAT expiry date to: $expiry_date" - cat > "$temp_pat_json" << EOF -{ - "displayName": "MCP-Server-Integration", - "scope": "vso.code vso.build_execute vso.work vso.project vso.profile", - "validTo": "$expiry_date", - "allOrgs": false -} -EOF - - # Create PAT using REST API - echo "Generating PAT for $org_name organization..." - pat_response=$(az rest --method post --uri "https://vssps.dev.azure.com/$org_name/_apis/tokens/pats?api-version=7.1-preview.1" --resource "499b84ac-1321-427f-aa17-267ca6975798" --body @"$temp_pat_json" 2>&1) - pat_status=$? - - # Clean up temp file - rm -f "$temp_pat_json" - - if [ $pat_status -ne 0 ]; then - echo -e "${RED}Error: Failed to create PAT${NC}" - echo -e "${RED}Status code: $pat_status${NC}" - echo -e "${RED}Error response:${NC}" - echo "$pat_response" - echo - echo "Switching to manual PAT creation..." - pat_method="2" - else - echo "PAT API response:" - echo "$pat_response" - echo - # Extract token from response using jq - pat_token=$(echo "$pat_response" | jq -r '.patToken.token') - - if [ "$pat_token" = "null" ] || [ -z "$pat_token" ]; then - echo -e "${RED}Failed to extract token from response.${NC}" - echo "Full response was:" - echo "$pat_response" - echo - echo "Switching to manual PAT creation..." - pat_method="2" - fi - fi -fi - -if [ "$pat_method" = "2" ]; then - # Open browser for manual PAT creation - pat_url="https://dev.azure.com/$org_name/_usersSettings/tokens" - - echo -e "${YELLOW}Opening browser to create a PAT manually...${NC}" - echo "Please create a PAT with the following scopes:" - echo "- Code (read) - vso.code" - echo "- Build (read and execute) - vso.build_execute" - echo "- Work items (read) - vso.work" - echo "- Project and Team (read) - vso.project" - echo "- User profile (read) - vso.profile" - - # Try to open browser using various commands - if command -v xdg-open &> /dev/null; then - xdg-open "$pat_url" &> /dev/null & - elif command -v open &> /dev/null; then - open "$pat_url" &> /dev/null & - elif command -v start &> /dev/null; then - start "$pat_url" &> /dev/null & - else - echo -e "${YELLOW}Could not open browser automatically.${NC}" - echo "Please visit: $pat_url" - fi - - echo -e "\nAfter creating the PAT, copy it and paste it here:" - read -p "Enter your PAT: " pat_token -fi - -if [ -z "$pat_token" ]; then - handle_error "No PAT provided. Cannot continue." - return 1 2>/dev/null || exit 1 -fi -should_continue || return 1 2>/dev/null || exit 1 - # Create .env file echo -e "\n${YELLOW}Step 5: Creating .env file...${NC}" @@ -399,10 +258,8 @@ AZURE_DEVOPS_ORG=$org_name # Azure DevOps Organization URL (required) AZURE_DEVOPS_ORG_URL=$org_url -# Azure DevOps Personal Access Token (required) -# Created via Azure CLI on $(date +"%b %d, %Y") -# Scopes: vso.code vso.build_execute vso.work vso.project vso.profile -AZURE_DEVOPS_PAT=$pat_token + +AZURE_DEVOPS_AUTH_METHOD=azure-identity EOF # Add default project if specified diff --git a/src/api/README.md b/src/api/README.md deleted file mode 100644 index 2375f90..0000000 --- a/src/api/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# Azure DevOps API Module - -> Internal module for authenticating and interacting with Azure DevOps APIs. - -## Overview - -This module provides authentication and client functionality for Azure DevOps APIs within the MCP Server project. It handles Personal Access Token (PAT) based authentication, client management, and standardized error handling. - -## Usage Examples - -### Authentication - -```typescript -import { AzureDevOpsClient } from '../api/client'; - -// Create a client instance -const client = new AzureDevOpsClient({ - pat: process.env.AZURE_DEVOPS_PAT, - orgUrl: 'https://dev.azure.com/myorg' -}); - -// Check authentication -if (await client.isAuthenticated()) { - console.log('Successfully authenticated'); -} -``` - -### API Access - -```typescript -// Get projects -const coreApi = await client.getCoreApi(); -const projects = await coreApi.getProjects(); - -// Get repositories -const gitApi = await client.getGitApi(); -const repos = await gitApi.getRepositories(); - -// Get work items -const witApi = await client.getWorkItemTrackingApi(); -const workItem = await witApi.getWorkItem(123); -``` - -### Error Handling - -```typescript -import { isAzureDevOpsError, formatAzureDevOpsError } from '../api/errors'; - -try { - await client.getCoreApi(); -} catch (error) { - if (isAzureDevOpsError(error)) { - console.error('Azure DevOps error:', formatAzureDevOpsError(error)); - } else { - console.error('Unknown error:', error); - } -} -``` - -## Available APIs - -- Core API (`getCoreApi`) -- Git API (`getGitApi`) -- Work Item Tracking API (`getWorkItemTrackingApi`) -- Build API (`getBuildApi`) -- Test API (`getTestApi`) -- Release API (`getReleaseApi`) -- Task Agent API (`getTaskAgentApi`) -- Task API (`getTaskApi`) \ No newline at end of file diff --git a/src/api/auth.ts b/src/api/auth.ts deleted file mode 100644 index 9a619e8..0000000 --- a/src/api/auth.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; -import { AzureDevOpsAuthenticationError } from '../common/errors'; - -/** - * Authentication configuration for Azure DevOps - */ -export interface AuthConfig { - /** - * Personal Access Token for Azure DevOps - */ - pat: string; - - /** - * Organization URL (e.g., https://dev.azure.com/myorg) - */ - orgUrl: string; -} - -/** - * Creates an authenticated client for Azure DevOps API - * - * @param config Authentication configuration - * @returns Authenticated WebApi client - * @throws {AzureDevOpsAuthenticationError} If authentication fails - */ -export async function createAuthenticatedClient( - config: AuthConfig | { orgUrl: string; pat: string } -): Promise { - if (!config.pat) { - throw new AzureDevOpsAuthenticationError('Personal Access Token (PAT) is required'); - } - - if (!config.orgUrl) { - throw new AzureDevOpsAuthenticationError('Organization URL is required'); - } - - try { - // Create authentication handler using PAT - const authHandler = getPersonalAccessTokenHandler(config.pat); - - // Create API client with the auth handler - const client = new WebApi(config.orgUrl, authHandler); - - // Test the connection by getting a simple API - await client.getLocationsApi(); - - return client; - } catch (error) { - throw new AzureDevOpsAuthenticationError( - `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}` - ); - } -} - -/** - * Validates that a Personal Access Token has the correct format - * - * @param pat Personal Access Token - * @returns true if PAT has valid format, false otherwise - */ -export function isValidPatFormat(pat: string): boolean { - if (!pat || pat.length < 64) { - return false; - } - - // Check if it's a base64 string - // PATs are minimum 64 characters and typically have base64 pattern - const base64Regex = /^[A-Za-z0-9+/=]+$/; - return base64Regex.test(pat); -} \ No newline at end of file diff --git a/src/features/organizations/index.ts b/src/features/organizations/index.ts new file mode 100644 index 0000000..a68498f --- /dev/null +++ b/src/features/organizations/index.ts @@ -0,0 +1,6 @@ +// Re-export schemas and types +export * from './schemas'; +export * from './types'; + +// Re-export features +export * from './list-organizations'; diff --git a/src/features/organizations/list-organizations/feature.test.ts b/src/features/organizations/list-organizations/feature.test.ts new file mode 100644 index 0000000..92579a7 --- /dev/null +++ b/src/features/organizations/list-organizations/feature.test.ts @@ -0,0 +1,262 @@ +import axios from 'axios'; +import { AzureDevOpsConfig } from '../../../shared/types'; +import { AzureDevOpsAuthenticationError } from '../../../shared/errors'; +import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; +import { AuthenticationMethod } from '../../../shared/auth'; +import { listOrganizations } from './feature'; + +// Mock dependencies +jest.mock('axios'); +jest.mock('@azure/identity'); + +describe('listOrganizations', () => { + const mockedAxios = axios as jest.Mocked; + const mockConfig: AzureDevOpsConfig = { + organizationUrl: 'https://dev.azure.com/testorg', + authMethod: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'test-pat', + }; + + const mockProfileResponse = { + data: { + publicAlias: 'test-alias', + }, + }; + + const mockOrgsResponse = { + data: { + value: [ + { + accountId: 'org-1', + accountName: 'Test Organization 1', + accountUri: 'https://dev.azure.com/org1', + }, + { + accountId: 'org-2', + accountName: 'Test Organization 2', + accountUri: 'https://dev.azure.com/org2', + }, + ], + }, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should list organizations using PAT authentication', async () => { + mockedAxios.get.mockImplementation((url) => { + if (url.includes('profiles/me')) { + return Promise.resolve(mockProfileResponse); + } else if (url.includes('accounts')) { + return Promise.resolve(mockOrgsResponse); + } + return Promise.reject(new Error('Invalid URL')); + }); + + const result = await listOrganizations(mockConfig); + + // Verify PAT auth header was used + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('profiles/me'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: expect.stringContaining('Basic '), + }), + }), + ); + + expect(result).toEqual([ + { + id: 'org-1', + name: 'Test Organization 1', + url: 'https://dev.azure.com/org1', + }, + { + id: 'org-2', + name: 'Test Organization 2', + url: 'https://dev.azure.com/org2', + }, + ]); + }); + + it('should list organizations using Azure Identity authentication', async () => { + const azureConfig: AzureDevOpsConfig = { + ...mockConfig, + authMethod: AuthenticationMethod.AzureIdentity, + personalAccessToken: undefined, + }; + + // Mock DefaultAzureCredential + const mockToken = { token: 'test-token' }; + const mockCredential = { + getToken: jest.fn().mockResolvedValue(mockToken), + }; + (DefaultAzureCredential as jest.Mock).mockImplementation( + () => mockCredential, + ); + + mockedAxios.get.mockImplementation((url) => { + if (url.includes('profiles/me')) { + return Promise.resolve(mockProfileResponse); + } else if (url.includes('accounts')) { + return Promise.resolve(mockOrgsResponse); + } + return Promise.reject(new Error('Invalid URL')); + }); + + const result = await listOrganizations(azureConfig); + + // Verify Bearer token was used + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('profiles/me'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }), + ); + + expect(result).toEqual([ + { + id: 'org-1', + name: 'Test Organization 1', + url: 'https://dev.azure.com/org1', + }, + { + id: 'org-2', + name: 'Test Organization 2', + url: 'https://dev.azure.com/org2', + }, + ]); + }); + + it('should list organizations using Azure CLI authentication', async () => { + const azureConfig: AzureDevOpsConfig = { + ...mockConfig, + authMethod: AuthenticationMethod.AzureCli, + personalAccessToken: undefined, + }; + + // Mock AzureCliCredential + const mockToken = { token: 'test-token' }; + const mockCredential = { + getToken: jest.fn().mockResolvedValue(mockToken), + }; + (AzureCliCredential as jest.Mock).mockImplementation(() => mockCredential); + + mockedAxios.get.mockImplementation((url) => { + if (url.includes('profiles/me')) { + return Promise.resolve(mockProfileResponse); + } else if (url.includes('accounts')) { + return Promise.resolve(mockOrgsResponse); + } + return Promise.reject(new Error('Invalid URL')); + }); + + const result = await listOrganizations(azureConfig); + + // Verify Bearer token was used + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('profiles/me'), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }), + ); + + expect(result).toEqual([ + { + id: 'org-1', + name: 'Test Organization 1', + url: 'https://dev.azure.com/org1', + }, + { + id: 'org-2', + name: 'Test Organization 2', + url: 'https://dev.azure.com/org2', + }, + ]); + }); + + it('should throw an error if Azure Identity token acquisition fails', async () => { + const azureConfig: AzureDevOpsConfig = { + ...mockConfig, + authMethod: AuthenticationMethod.AzureIdentity, + personalAccessToken: undefined, + }; + + // Mock DefaultAzureCredential with failure + const mockCredential = { + getToken: jest + .fn() + .mockRejectedValue(new Error('Token acquisition failed')), + }; + (DefaultAzureCredential as jest.Mock).mockImplementation( + () => mockCredential, + ); + + await expect(listOrganizations(azureConfig)).rejects.toThrow( + 'Failed to list organizations: Token acquisition failed', + ); + }); + + it('should throw an error if Azure Identity returns a null token', async () => { + const azureConfig: AzureDevOpsConfig = { + ...mockConfig, + authMethod: AuthenticationMethod.AzureIdentity, + personalAccessToken: undefined, + }; + + // Mock DefaultAzureCredential returning null token + const mockCredential = { + getToken: jest.fn().mockResolvedValue(null), + }; + (DefaultAzureCredential as jest.Mock).mockImplementation( + () => mockCredential, + ); + + await expect(listOrganizations(azureConfig)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw an error if profile API fails', async () => { + mockedAxios.get.mockImplementation((url) => { + if (url.includes('profiles/me')) { + return Promise.reject(new Error('Profile API error')); + } + return Promise.reject(new Error('Invalid URL')); + }); + + await expect(listOrganizations(mockConfig)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw an error if profile has no publicAlias', async () => { + mockedAxios.get.mockImplementation((url) => { + if (url.includes('profiles/me')) { + return Promise.resolve({ data: {} }); // No publicAlias + } + return Promise.reject(new Error('Invalid URL')); + }); + + await expect(listOrganizations(mockConfig)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw an error when PAT is missing with PAT authentication', async () => { + const config: AzureDevOpsConfig = { + organizationUrl: 'https://dev.azure.com/testorg', + authMethod: AuthenticationMethod.PersonalAccessToken, + // No PAT provided + }; + + await expect(listOrganizations(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); +}); diff --git a/src/operations/organizations.ts b/src/features/organizations/list-organizations/feature.ts similarity index 56% rename from src/operations/organizations.ts rename to src/features/organizations/list-organizations/feature.ts index ee0cbd8..5201add 100644 --- a/src/operations/organizations.ts +++ b/src/features/organizations/list-organizations/feature.ts @@ -1,91 +1,75 @@ import axios from 'axios'; -import { z } from 'zod'; -import { AzureDevOpsConfig } from '../types/config'; -import { AzureDevOpsAuthenticationError } from '../common/errors'; +import { AzureDevOpsConfig } from '../../../shared/types'; +import { + AzureDevOpsAuthenticationError, + AzureDevOpsError, +} from '../../../shared/errors'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; -import { AuthenticationMethod } from '../auth/auth-factory'; - -/** - * Organization interface - */ -export interface Organization { - /** - * The ID of the organization - */ - id: string; - - /** - * The name of the organization - */ - name: string; - - /** - * The URL of the organization - */ - url: string; -} - -/** - * Schema for the list organizations response - */ -export const ListOrganizationsSchema = z.object({}); - -/** - * Azure DevOps resource ID for token acquisition - */ -const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; +import { AuthenticationMethod } from '../../../shared/auth'; +import { Organization, AZURE_DEVOPS_RESOURCE_ID } from '../types'; /** * Lists all Azure DevOps organizations accessible to the authenticated user - * + * * Note: This function uses Axios directly rather than the Azure DevOps Node API * because the WebApi client doesn't support the organizations endpoint. - * + * * @param config The Azure DevOps configuration * @returns Array of organizations * @throws {AzureDevOpsAuthenticationError} If authentication fails */ -export async function listOrganizations(config: AzureDevOpsConfig): Promise { +export async function listOrganizations( + config: AzureDevOpsConfig, +): Promise { try { // Determine auth method and create appropriate authorization header let authHeader: string; - + if (config.authMethod === AuthenticationMethod.PersonalAccessToken) { // PAT authentication if (!config.personalAccessToken) { - throw new AzureDevOpsAuthenticationError('Personal Access Token (PAT) is required when using PAT authentication'); + throw new AzureDevOpsAuthenticationError( + 'Personal Access Token (PAT) is required when using PAT authentication', + ); } authHeader = createBasicAuthHeader(config.personalAccessToken); } else { // Azure Identity authentication (DefaultAzureCredential or AzureCliCredential) - const credential = config.authMethod === AuthenticationMethod.AzureCli - ? new AzureCliCredential() - : new DefaultAzureCredential(); - - const token = await credential.getToken(`${AZURE_DEVOPS_RESOURCE_ID}/.default`); - + const credential = + config.authMethod === AuthenticationMethod.AzureCli + ? new AzureCliCredential() + : new DefaultAzureCredential(); + + const token = await credential.getToken( + `${AZURE_DEVOPS_RESOURCE_ID}/.default`, + ); + if (!token || !token.token) { - throw new AzureDevOpsAuthenticationError('Failed to acquire Azure Identity token'); + throw new AzureDevOpsAuthenticationError( + 'Failed to acquire Azure Identity token', + ); } - + authHeader = `Bearer ${token.token}`; } // Step 1: Get the user profile to get the publicAlias const profileResponse = await axios.get( 'https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=6.0', - { + { headers: { - 'Authorization': authHeader, + Authorization: authHeader, 'Content-Type': 'application/json', - } - } + }, + }, ); // Extract the publicAlias const publicAlias = profileResponse.data.publicAlias; if (!publicAlias) { - throw new AzureDevOpsAuthenticationError('Unable to get user publicAlias from profile'); + throw new AzureDevOpsAuthenticationError( + 'Unable to get user publicAlias from profile', + ); } // Step 2: Get organizations using the publicAlias @@ -93,10 +77,10 @@ export async function listOrganizations(config: AzureDevOpsConfig): Promise; jest.mock('@azure/identity', () => { return { DefaultAzureCredential: jest.fn().mockImplementation(() => ({ - getToken: jest.fn().mockResolvedValue({ token: 'mock-token' }) + getToken: jest.fn().mockResolvedValue({ token: 'mock-token' }), })), AzureCliCredential: jest.fn().mockImplementation(() => ({ - getToken: jest.fn().mockResolvedValue({ token: 'mock-cli-token' }) - })) + getToken: jest.fn().mockResolvedValue({ token: 'mock-cli-token' }), + })), }; }); @@ -47,7 +47,7 @@ describe('listOrganizations', () => { publicAlias: 'test-user-id', emailAddress: 'test@example.com', id: 'test-user-id', - } + }, }; const mockOrganizationsResponse = { @@ -63,9 +63,9 @@ describe('listOrganizations', () => { accountId: 'org2-id', accountName: 'org2-name', accountUri: 'https://dev.azure.com/org2-name', - } - ] - } + }, + ], + }, }; beforeEach(() => { @@ -98,7 +98,7 @@ describe('listOrganizations', () => { id: 'org2-id', name: 'org2-name', url: 'https://dev.azure.com/org2-name', - } + }, ]); // Verify API calls @@ -110,7 +110,7 @@ describe('listOrganizations', () => { headers: expect.objectContaining({ Authorization: expect.any(String), }), - }) + }), ); expect(mockedAxios.get).toHaveBeenNthCalledWith( 2, @@ -119,7 +119,7 @@ describe('listOrganizations', () => { headers: expect.objectContaining({ Authorization: expect.any(String), }), - }) + }), ); }); @@ -148,12 +148,12 @@ describe('listOrganizations', () => { id: 'org2-id', name: 'org2-name', url: 'https://dev.azure.com/org2-name', - } + }, ]); // Verify DefaultAzureCredential was called expect(mockedDefaultAzureCredential).toHaveBeenCalledTimes(1); - + // Verify API calls used Bearer token expect(mockedAxios.get).toHaveBeenCalledTimes(2); expect(mockedAxios.get).toHaveBeenNthCalledWith( @@ -163,7 +163,7 @@ describe('listOrganizations', () => { headers: expect.objectContaining({ Authorization: 'Bearer mock-token', }), - }) + }), ); }); @@ -192,12 +192,12 @@ describe('listOrganizations', () => { id: 'org2-id', name: 'org2-name', url: 'https://dev.azure.com/org2-name', - } + }, ]); // Verify AzureCliCredential was called expect(mockedAzureCliCredential).toHaveBeenCalledTimes(1); - + // Verify API calls used Bearer token expect(mockedAxios.get).toHaveBeenCalledTimes(2); expect(mockedAxios.get).toHaveBeenNthCalledWith( @@ -207,7 +207,7 @@ describe('listOrganizations', () => { headers: expect.objectContaining({ Authorization: 'Bearer mock-cli-token', }), - }) + }), ); }); @@ -218,20 +218,26 @@ describe('listOrganizations', () => { jest.doMock('@azure/identity', () => { return { DefaultAzureCredential: jest.fn().mockImplementation(() => ({ - getToken: jest.fn().mockRejectedValue(new AzureDevOpsAuthenticationError('Token acquisition failed')) + getToken: jest + .fn() + .mockRejectedValue( + new AzureDevOpsAuthenticationError('Token acquisition failed'), + ), })), - AzureCliCredential: jest.fn() + AzureCliCredential: jest.fn(), }; }); - + // Re-import listOrganizations with mocked dependencies - const { listOrganizations: mockedListOrgs } = require('../../../../src/operations/organizations'); + const { listOrganizations: mockedListOrgs } = require('./feature'); // Call the function and expect it to throw await expect(mockedListOrgs(mockAzureIdentityConfig)).rejects.toThrow( - AzureDevOpsAuthenticationError + new AzureDevOpsAuthenticationError( + 'Failed to list organizations: Token acquisition failed', + ), ); - + // Reset mocks for subsequent tests jest.resetModules(); jest.doMock('@azure/identity', () => originalModule); @@ -241,12 +247,12 @@ describe('listOrganizations', () => { // Mock with null token const mockGetToken = jest.fn().mockResolvedValue(null); mockedDefaultAzureCredential.mockImplementation(() => ({ - getToken: mockGetToken + getToken: mockGetToken, })); // Call the function and expect it to throw await expect(listOrganizations(mockAzureIdentityConfig)).rejects.toThrow( - AzureDevOpsAuthenticationError + AzureDevOpsAuthenticationError, ); }); @@ -258,19 +264,27 @@ describe('listOrganizations', () => { return { DefaultAzureCredential: jest.fn(), AzureCliCredential: jest.fn().mockImplementation(() => ({ - getToken: jest.fn().mockRejectedValue(new AzureDevOpsAuthenticationError('CLI token acquisition failed')) - })) + getToken: jest + .fn() + .mockRejectedValue( + new AzureDevOpsAuthenticationError( + 'CLI token acquisition failed', + ), + ), + })), }; }); - + // Re-import listOrganizations with mocked dependencies - const { listOrganizations: mockedListOrgs } = require('../../../../src/operations/organizations'); + const { listOrganizations: mockedListOrgs } = require('./feature'); // Call the function and expect it to throw await expect(mockedListOrgs(mockAzureCliConfig)).rejects.toThrow( - AzureDevOpsAuthenticationError + new AzureDevOpsAuthenticationError( + 'Failed to list organizations: CLI token acquisition failed', + ), ); - + // Reset mocks for subsequent tests jest.resetModules(); jest.doMock('@azure/identity', () => originalModule); @@ -279,7 +293,7 @@ describe('listOrganizations', () => { it('should throw an error if profile API fails', async () => { // Create a mock error with profile in the message const profileError = new Error('Unable to get user profile'); - + // Setup axios mocks mockedAxios.get.mockImplementationOnce((url: string) => { if (url.includes('profiles/me')) { @@ -290,7 +304,7 @@ describe('listOrganizations', () => { // Call the function and expect it to throw await expect(listOrganizations(mockConfig)).rejects.toThrow( - AzureDevOpsAuthenticationError + AzureDevOpsAuthenticationError, ); }); @@ -323,7 +337,7 @@ describe('listOrganizations', () => { displayName: 'Test User', // Missing publicAlias emailAddress: 'test@example.com', - } + }, }); } return Promise.reject(new Error(`Unexpected URL: ${url}`)); @@ -343,7 +357,7 @@ describe('listOrganizations', () => { // Call the function and expect it to throw await expect(listOrganizations(configWithoutPat)).rejects.toThrow( - 'Personal Access Token (PAT) is required when using PAT authentication' + 'Personal Access Token (PAT) is required when using PAT authentication', ); }); -}); \ No newline at end of file +}); diff --git a/src/features/organizations/schemas.ts b/src/features/organizations/schemas.ts new file mode 100644 index 0000000..c73a2f8 --- /dev/null +++ b/src/features/organizations/schemas.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +/** + * Schema for the list organizations request + * Note: This is an empty schema because the operation doesn't require any parameters + */ +export const ListOrganizationsSchema = z.object({}); diff --git a/src/features/organizations/types.ts b/src/features/organizations/types.ts new file mode 100644 index 0000000..02cbbd4 --- /dev/null +++ b/src/features/organizations/types.ts @@ -0,0 +1,24 @@ +/** + * Organization interface + */ +export interface Organization { + /** + * The ID of the organization + */ + id: string; + + /** + * The name of the organization + */ + name: string; + + /** + * The URL of the organization + */ + url: string; +} + +/** + * Azure DevOps resource ID for token acquisition + */ +export const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; diff --git a/src/features/projects/get-project/feature.test.ts b/src/features/projects/get-project/feature.test.ts new file mode 100644 index 0000000..3a1a08c --- /dev/null +++ b/src/features/projects/get-project/feature.test.ts @@ -0,0 +1,83 @@ +import { WebApi } from 'azure-devops-node-api'; +import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; +import { getProject } from './feature'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('getProject', () => { + let mockConnection: jest.Mocked; + let mockCoreApi: any; + + const mockProject: TeamProject = { + id: 'project-1', + name: 'Test Project', + description: 'A test project', + url: 'https://dev.azure.com/test/project1', + state: 1, + revision: 1, + visibility: 0, + lastUpdateTime: new Date(), + }; + + beforeEach(() => { + mockCoreApi = { + getProject: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getCoreApi = jest.fn().mockResolvedValue(mockCoreApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should get a project by id', async () => { + mockCoreApi.getProject.mockResolvedValue(mockProject); + + const project = await getProject(mockConnection, 'project-1'); + + expect(mockConnection.getCoreApi).toHaveBeenCalled(); + expect(mockCoreApi.getProject).toHaveBeenCalledWith('project-1'); + expect(project).toEqual(mockProject); + }); + + it('should throw AzureDevOpsResourceNotFoundError when project is not found', async () => { + mockCoreApi.getProject.mockResolvedValue(null); + + await expect(getProject(mockConnection, 'non-existent')).rejects.toThrow( + new AzureDevOpsResourceNotFoundError("Project 'non-existent' not found"), + ); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockCoreApi.getProject.mockRejectedValue(error); + + await expect(getProject(mockConnection, 'project-1')).rejects.toThrow( + 'Failed to get project: API error', + ); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsResourceNotFoundError('Custom error'); + mockCoreApi.getProject.mockRejectedValue(error); + + await expect(getProject(mockConnection, 'project-1')).rejects.toThrow( + error, + ); + }); + + // Additional test cases from operations.test.ts + it('should propagate AzureDevOpsResourceNotFoundError from operations.test.ts', async () => { + const error = new AzureDevOpsResourceNotFoundError('Project not found'); + mockCoreApi.getProject.mockRejectedValue(error); + + await expect(getProject(mockConnection, 'project-id')).rejects.toThrow( + AzureDevOpsResourceNotFoundError, + ); + }); +}); diff --git a/src/features/projects/get-project/feature.ts b/src/features/projects/get-project/feature.ts new file mode 100644 index 0000000..fa8a371 --- /dev/null +++ b/src/features/projects/get-project/feature.ts @@ -0,0 +1,39 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { TeamProject } from '../types'; + +/** + * Get a project by ID or name + * + * @param connection The Azure DevOps WebApi connection + * @param projectId The ID or name of the project + * @returns The project details + * @throws {AzureDevOpsResourceNotFoundError} If the project is not found + */ +export async function getProject( + connection: WebApi, + projectId: string, +): Promise { + try { + const coreApi = await connection.getCoreApi(); + const project = await coreApi.getProject(projectId); + + if (!project) { + throw new AzureDevOpsResourceNotFoundError( + `Project '${projectId}' not found`, + ); + } + + return project; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to get project: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/projects/get-project/index.ts b/src/features/projects/get-project/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/projects/get-project/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/projects/get-project/schema.ts b/src/features/projects/get-project/schema.ts new file mode 100644 index 0000000..2e9cceb --- /dev/null +++ b/src/features/projects/get-project/schema.ts @@ -0,0 +1,3 @@ +import { GetProjectSchema } from '../schemas'; + +export { GetProjectSchema }; diff --git a/src/features/projects/index.ts b/src/features/projects/index.ts new file mode 100644 index 0000000..3b3ed10 --- /dev/null +++ b/src/features/projects/index.ts @@ -0,0 +1,7 @@ +// Re-export schemas and types +export * from './schemas'; +export * from './types'; + +// Re-export features +export * from './get-project'; +export * from './list-projects'; diff --git a/src/features/projects/list-projects/feature.test.ts b/src/features/projects/list-projects/feature.test.ts new file mode 100644 index 0000000..abd4764 --- /dev/null +++ b/src/features/projects/list-projects/feature.test.ts @@ -0,0 +1,100 @@ +import { WebApi } from 'azure-devops-node-api'; +import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { listProjects } from './feature'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('listProjects', () => { + let mockConnection: jest.Mocked; + let mockCoreApi: any; + + const mockProjects: TeamProject[] = [ + { + id: 'project-1', + name: 'Test Project 1', + description: 'A test project', + url: 'https://dev.azure.com/test/project1', + state: 1, + revision: 1, + visibility: 0, + lastUpdateTime: new Date(), + }, + { + id: 'project-2', + name: 'Test Project 2', + description: 'Another test project', + url: 'https://dev.azure.com/test/project2', + state: 1, + revision: 1, + visibility: 0, + lastUpdateTime: new Date(), + }, + ]; + + beforeEach(() => { + mockCoreApi = { + getProjects: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getCoreApi = jest.fn().mockResolvedValue(mockCoreApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should list projects with no options', async () => { + mockCoreApi.getProjects.mockResolvedValue(mockProjects); + + const projects = await listProjects(mockConnection); + + expect(mockConnection.getCoreApi).toHaveBeenCalled(); + expect(mockCoreApi.getProjects).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + undefined, + ); + expect(projects).toEqual(mockProjects); + }); + + it('should pass options to the API', async () => { + mockCoreApi.getProjects.mockResolvedValue(mockProjects); + + const options = { + stateFilter: 1, + top: 10, + skip: 5, + continuationToken: 100, + }; + + await listProjects(mockConnection, options); + + expect(mockCoreApi.getProjects).toHaveBeenCalledWith( + options.stateFilter, + options.top, + options.skip, + options.continuationToken, + ); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockCoreApi.getProjects.mockRejectedValue(error); + + await expect(listProjects(mockConnection)).rejects.toThrow( + 'Failed to list projects: API error', + ); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsError('Custom error'); + mockCoreApi.getProjects.mockRejectedValue(error); + + await expect(listProjects(mockConnection)).rejects.toThrow(error); + }); +}); diff --git a/src/features/projects/list-projects/feature.ts b/src/features/projects/list-projects/feature.ts new file mode 100644 index 0000000..2e42be7 --- /dev/null +++ b/src/features/projects/list-projects/feature.ts @@ -0,0 +1,34 @@ +import { WebApi } from 'azure-devops-node-api'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { ListProjectsOptions, TeamProject } from '../types'; + +/** + * List all projects in the organization + * + * @param connection The Azure DevOps WebApi connection + * @param options Optional parameters for listing projects + * @returns Array of projects + */ +export async function listProjects( + connection: WebApi, + options: ListProjectsOptions = {}, +): Promise { + try { + const coreApi = await connection.getCoreApi(); + const projects = await coreApi.getProjects( + options.stateFilter, + options.top, + options.skip, + options.continuationToken, + ); + + return projects; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to list projects: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/projects/list-projects/index.ts b/src/features/projects/list-projects/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/projects/list-projects/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/projects/list-projects/schema.ts b/src/features/projects/list-projects/schema.ts new file mode 100644 index 0000000..a04e87f --- /dev/null +++ b/src/features/projects/list-projects/schema.ts @@ -0,0 +1,3 @@ +import { ListProjectsSchema } from '../schemas'; + +export { ListProjectsSchema }; diff --git a/src/features/projects/schemas.ts b/src/features/projects/schemas.ts new file mode 100644 index 0000000..de00f9c --- /dev/null +++ b/src/features/projects/schemas.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +/** + * Schema for getting a project + */ +export const GetProjectSchema = z.object({ + projectId: z.string().describe('The ID or name of the project'), +}); + +/** + * Schema for listing projects + */ +export const ListProjectsSchema = z.object({ + stateFilter: z + .number() + .optional() + .describe( + 'Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new)', + ), + top: z.number().optional().describe('Maximum number of projects to return'), + skip: z.number().optional().describe('Number of projects to skip'), + continuationToken: z + .number() + .optional() + .describe('Gets the projects after the continuation token provided'), +}); diff --git a/src/features/projects/types.ts b/src/features/projects/types.ts new file mode 100644 index 0000000..b2c0570 --- /dev/null +++ b/src/features/projects/types.ts @@ -0,0 +1,14 @@ +import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; + +/** + * Options for listing projects + */ +export interface ListProjectsOptions { + stateFilter?: number; + top?: number; + skip?: number; + continuationToken?: number; +} + +// Re-export TeamProject type for convenience +export type { TeamProject }; diff --git a/src/features/repositories/get-repository/feature.test.ts b/src/features/repositories/get-repository/feature.test.ts new file mode 100644 index 0000000..c3b4e72 --- /dev/null +++ b/src/features/repositories/get-repository/feature.test.ts @@ -0,0 +1,88 @@ +import { WebApi } from 'azure-devops-node-api'; +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; +import { getRepository } from './feature'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('getRepository', () => { + let mockConnection: jest.Mocked; + let mockGitApi: any; + + const mockRepository: GitRepository = { + id: 'repo-1', + name: 'Test Repository', + url: 'https://dev.azure.com/test/project1/_git/repo1', + project: { + id: 'project-1', + name: 'Test Project', + }, + defaultBranch: 'refs/heads/main', + size: 1024, + remoteUrl: 'https://dev.azure.com/test/project1/_git/repo1', + sshUrl: 'git@ssh.dev.azure.com:v3/test/project1/repo1', + webUrl: 'https://dev.azure.com/test/project1/_git/repo1', + }; + + beforeEach(() => { + mockGitApi = { + getRepository: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should get a repository by id', async () => { + mockGitApi.getRepository.mockResolvedValue(mockRepository); + + const repository = await getRepository( + mockConnection, + 'project-1', + 'repo-1', + ); + + expect(mockConnection.getGitApi).toHaveBeenCalled(); + expect(mockGitApi.getRepository).toHaveBeenCalledWith( + 'repo-1', + 'project-1', + ); + expect(repository).toEqual(mockRepository); + }); + + it('should throw AzureDevOpsResourceNotFoundError when repository is not found', async () => { + mockGitApi.getRepository.mockResolvedValue(null); + + await expect( + getRepository(mockConnection, 'project-1', 'non-existent'), + ).rejects.toThrow( + new AzureDevOpsResourceNotFoundError( + "Repository 'non-existent' not found in project 'project-1'", + ), + ); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockGitApi.getRepository.mockRejectedValue(error); + + await expect( + getRepository(mockConnection, 'project-1', 'repo-1'), + ).rejects.toThrow('Failed to get repository: API error'); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsResourceNotFoundError('Custom error'); + mockGitApi.getRepository.mockRejectedValue(error); + + await expect( + getRepository(mockConnection, 'project-1', 'repo-1'), + ).rejects.toThrow(error); + }); +}); diff --git a/src/features/repositories/get-repository/feature.ts b/src/features/repositories/get-repository/feature.ts new file mode 100644 index 0000000..7fcf10d --- /dev/null +++ b/src/features/repositories/get-repository/feature.ts @@ -0,0 +1,41 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { GitRepository } from '../types'; + +/** + * Get a repository by ID or name + * + * @param connection The Azure DevOps WebApi connection + * @param projectId The ID or name of the project + * @param repositoryId The ID or name of the repository + * @returns The repository details + * @throws {AzureDevOpsResourceNotFoundError} If the repository is not found + */ +export async function getRepository( + connection: WebApi, + projectId: string, + repositoryId: string, +): Promise { + try { + const gitApi = await connection.getGitApi(); + const repository = await gitApi.getRepository(repositoryId, projectId); + + if (!repository) { + throw new AzureDevOpsResourceNotFoundError( + `Repository '${repositoryId}' not found in project '${projectId}'`, + ); + } + + return repository; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to get repository: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/repositories/get-repository/index.ts b/src/features/repositories/get-repository/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/repositories/get-repository/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/repositories/get-repository/schema.ts b/src/features/repositories/get-repository/schema.ts new file mode 100644 index 0000000..5af6c69 --- /dev/null +++ b/src/features/repositories/get-repository/schema.ts @@ -0,0 +1,3 @@ +import { GetRepositorySchema } from '../schemas'; + +export { GetRepositorySchema }; diff --git a/src/features/repositories/index.ts b/src/features/repositories/index.ts new file mode 100644 index 0000000..d305e9b --- /dev/null +++ b/src/features/repositories/index.ts @@ -0,0 +1,7 @@ +// Re-export schemas and types +export * from './schemas'; +export * from './types'; + +// Re-export features +export * from './get-repository'; +export * from './list-repositories'; diff --git a/src/features/repositories/list-repositories/feature.test.ts b/src/features/repositories/list-repositories/feature.test.ts new file mode 100644 index 0000000..1449fc9 --- /dev/null +++ b/src/features/repositories/list-repositories/feature.test.ts @@ -0,0 +1,110 @@ +import { WebApi } from 'azure-devops-node-api'; +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { listRepositories } from './feature'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('listRepositories', () => { + let mockConnection: jest.Mocked; + let mockGitApi: any; + + const mockRepositories: GitRepository[] = [ + { + id: 'repo-1', + name: 'Test Repository 1', + url: 'https://dev.azure.com/test/project1/_git/repo1', + project: { + id: 'project-1', + name: 'Test Project', + }, + defaultBranch: 'refs/heads/main', + size: 1024, + remoteUrl: 'https://dev.azure.com/test/project1/_git/repo1', + sshUrl: 'git@ssh.dev.azure.com:v3/test/project1/repo1', + webUrl: 'https://dev.azure.com/test/project1/_git/repo1', + }, + { + id: 'repo-2', + name: 'Test Repository 2', + url: 'https://dev.azure.com/test/project1/_git/repo2', + project: { + id: 'project-1', + name: 'Test Project', + }, + defaultBranch: 'refs/heads/main', + size: 2048, + remoteUrl: 'https://dev.azure.com/test/project1/_git/repo2', + sshUrl: 'git@ssh.dev.azure.com:v3/test/project1/repo2', + webUrl: 'https://dev.azure.com/test/project1/_git/repo2', + }, + ]; + + beforeEach(() => { + mockGitApi = { + getRepositories: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should list repositories in a project', async () => { + mockGitApi.getRepositories.mockResolvedValue(mockRepositories); + + const repositories = await listRepositories(mockConnection, { + projectId: 'project-1', + }); + + expect(mockConnection.getGitApi).toHaveBeenCalled(); + expect(mockGitApi.getRepositories).toHaveBeenCalledWith( + 'project-1', + undefined, + ); + expect(repositories).toEqual(mockRepositories); + }); + + it('should pass includeLinks option to the API', async () => { + mockGitApi.getRepositories.mockResolvedValue(mockRepositories); + + await listRepositories(mockConnection, { + projectId: 'project-1', + includeLinks: true, + }); + + expect(mockGitApi.getRepositories).toHaveBeenCalledWith('project-1', true); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockGitApi.getRepositories.mockRejectedValue(error); + + await expect( + listRepositories(mockConnection, { projectId: 'project-1' }), + ).rejects.toThrow('Failed to list repositories: API error'); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsError('Custom error'); + mockGitApi.getRepositories.mockRejectedValue(error); + + await expect( + listRepositories(mockConnection, { projectId: 'project-1' }), + ).rejects.toThrow(error); + }); + + it('should handle non-Error objects in catch block', async () => { + // Mock a string error (not an Error instance) + mockGitApi.getRepositories.mockRejectedValue('String error message'); + + await expect( + listRepositories(mockConnection, { projectId: 'project-1' }), + ).rejects.toThrow('Failed to list repositories: String error message'); + }); +}); diff --git a/src/features/repositories/list-repositories/feature.ts b/src/features/repositories/list-repositories/feature.ts new file mode 100644 index 0000000..6105e51 --- /dev/null +++ b/src/features/repositories/list-repositories/feature.ts @@ -0,0 +1,32 @@ +import { WebApi } from 'azure-devops-node-api'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { ListRepositoriesOptions, GitRepository } from '../types'; + +/** + * List repositories in a project + * + * @param connection The Azure DevOps WebApi connection + * @param options Parameters for listing repositories + * @returns Array of repositories + */ +export async function listRepositories( + connection: WebApi, + options: ListRepositoriesOptions, +): Promise { + try { + const gitApi = await connection.getGitApi(); + const repositories = await gitApi.getRepositories( + options.projectId, + options.includeLinks, + ); + + return repositories; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/repositories/list-repositories/index.ts b/src/features/repositories/list-repositories/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/repositories/list-repositories/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/repositories/list-repositories/schema.ts b/src/features/repositories/list-repositories/schema.ts new file mode 100644 index 0000000..0d57f81 --- /dev/null +++ b/src/features/repositories/list-repositories/schema.ts @@ -0,0 +1,3 @@ +import { ListRepositoriesSchema } from '../schemas'; + +export { ListRepositoriesSchema }; diff --git a/src/features/repositories/schemas.ts b/src/features/repositories/schemas.ts new file mode 100644 index 0000000..fdc0e5e --- /dev/null +++ b/src/features/repositories/schemas.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +/** + * Schema for getting a repository + */ +export const GetRepositorySchema = z.object({ + projectId: z.string().describe('The ID or name of the project'), + repositoryId: z.string().describe('The ID or name of the repository'), +}); + +/** + * Schema for listing repositories + */ +export const ListRepositoriesSchema = z.object({ + projectId: z.string().describe('The ID or name of the project'), + includeLinks: z + .boolean() + .optional() + .describe('Whether to include reference links'), +}); diff --git a/src/features/repositories/types.ts b/src/features/repositories/types.ts new file mode 100644 index 0000000..f74923c --- /dev/null +++ b/src/features/repositories/types.ts @@ -0,0 +1,12 @@ +import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; + +/** + * Options for listing repositories + */ +export interface ListRepositoriesOptions { + projectId: string; + includeLinks?: boolean; +} + +// Re-export GitRepository type for convenience +export type { GitRepository }; diff --git a/src/features/work-items/create-work-item/feature.test.ts b/src/features/work-items/create-work-item/feature.test.ts new file mode 100644 index 0000000..7890d5f --- /dev/null +++ b/src/features/work-items/create-work-item/feature.test.ts @@ -0,0 +1,168 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItem } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { createWorkItem } from './feature'; +import { CreateWorkItemOptions } from '../types'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('createWorkItem', () => { + let mockConnection: jest.Mocked; + let mockWitApi: any; + + const mockWorkItem: WorkItem = { + id: 123, + rev: 1, + fields: { + 'System.Id': 123, + 'System.Title': 'Test Work Item', + 'System.State': 'New', + }, + url: 'https://dev.azure.com/test/project/_apis/wit/workItems/123', + }; + + const defaultOptions: CreateWorkItemOptions = { + title: 'Test Work Item', + }; + + beforeEach(() => { + mockWitApi = { + createWorkItem: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getWorkItemTrackingApi = jest + .fn() + .mockResolvedValue(mockWitApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should create a work item with required fields', async () => { + mockWitApi.createWorkItem.mockResolvedValue(mockWorkItem); + + const workItem = await createWorkItem( + mockConnection, + 'TestProject', + 'Task', + defaultOptions, + ); + + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWitApi.createWorkItem).toHaveBeenCalledWith( + [ + { + op: 'add', + path: '/fields/System.Title', + value: 'Test Work Item', + }, + ], + {}, + 'TestProject', + 'Task', + ); + + expect(workItem).toEqual(mockWorkItem); + }); + + it('should include optional fields when provided', async () => { + mockWitApi.createWorkItem.mockResolvedValue(mockWorkItem); + + const options: CreateWorkItemOptions = { + title: 'Test Work Item', + description: 'Test Description', + assignedTo: 'user@example.com', + areaPath: 'TestProject\\Area', + iterationPath: 'TestProject\\Iteration1', + priority: 1, + additionalFields: { + 'Custom.Field': 'Custom Value', + }, + }; + + await createWorkItem(mockConnection, 'TestProject', 'Task', options); + + const expectedDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: 'Test Work Item', + }, + { + op: 'add', + path: '/fields/System.Description', + value: 'Test Description', + }, + { + op: 'add', + path: '/fields/System.AssignedTo', + value: 'user@example.com', + }, + { + op: 'add', + path: '/fields/System.AreaPath', + value: 'TestProject\\Area', + }, + { + op: 'add', + path: '/fields/System.IterationPath', + value: 'TestProject\\Iteration1', + }, + { + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: 1, + }, + { + op: 'add', + path: '/fields/Custom.Field', + value: 'Custom Value', + }, + ]; + + expect(mockWitApi.createWorkItem).toHaveBeenCalledWith( + expectedDocument, + {}, + 'TestProject', + 'Task', + ); + }); + + it('should throw an error when title is missing', async () => { + await expect( + createWorkItem(mockConnection, 'TestProject', 'Task', { + title: '', + } as CreateWorkItemOptions), + ).rejects.toThrow('Title is required'); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockWitApi.createWorkItem.mockRejectedValue(error); + + await expect( + createWorkItem(mockConnection, 'TestProject', 'Task', defaultOptions), + ).rejects.toThrow('Failed to create work item: API error'); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsError('Custom error'); + mockWitApi.createWorkItem.mockRejectedValue(error); + + await expect( + createWorkItem(mockConnection, 'TestProject', 'Task', defaultOptions), + ).rejects.toThrow(error); + }); + + it('should throw an error when work item creation fails', async () => { + mockWitApi.createWorkItem.mockResolvedValue(null); + + await expect( + createWorkItem(mockConnection, 'TestProject', 'Task', defaultOptions), + ).rejects.toThrow('Failed to create work item'); + }); +}); diff --git a/src/features/work-items/create-work-item/feature.ts b/src/features/work-items/create-work-item/feature.ts new file mode 100644 index 0000000..39d7dfe --- /dev/null +++ b/src/features/work-items/create-work-item/feature.ts @@ -0,0 +1,110 @@ +import { WebApi } from 'azure-devops-node-api'; +import { AzureDevOpsError } from '../../../shared/errors'; +import { CreateWorkItemOptions, WorkItem } from '../types'; + +/** + * Create a work item + * + * @param connection The Azure DevOps WebApi connection + * @param projectId The ID or name of the project + * @param workItemType The type of work item to create (e.g., "Task", "Bug", "User Story") + * @param options Options for creating the work item + * @returns The created work item + */ +export async function createWorkItem( + connection: WebApi, + projectId: string, + workItemType: string, + options: CreateWorkItemOptions, +): Promise { + try { + if (!options.title) { + throw new Error('Title is required'); + } + + const witApi = await connection.getWorkItemTrackingApi(); + + // Create the JSON patch document + const document = []; + + // Add required fields + document.push({ + op: 'add', + path: '/fields/System.Title', + value: options.title, + }); + + // Add optional fields if provided + if (options.description) { + document.push({ + op: 'add', + path: '/fields/System.Description', + value: options.description, + }); + } + + if (options.assignedTo) { + document.push({ + op: 'add', + path: '/fields/System.AssignedTo', + value: options.assignedTo, + }); + } + + if (options.areaPath) { + document.push({ + op: 'add', + path: '/fields/System.AreaPath', + value: options.areaPath, + }); + } + + if (options.iterationPath) { + document.push({ + op: 'add', + path: '/fields/System.IterationPath', + value: options.iterationPath, + }); + } + + if (options.priority !== undefined) { + document.push({ + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: options.priority, + }); + } + + // Add any additional fields + if (options.additionalFields) { + for (const [key, value] of Object.entries(options.additionalFields)) { + document.push({ + op: 'add', + path: `/fields/${key}`, + value: value, + }); + } + } + + // Create the work item + const workItem = await witApi.createWorkItem( + document, + {}, + projectId, + workItemType, + ); + + if (!workItem) { + throw new Error('Failed to create work item'); + } + + return workItem; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to create work item: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/work-items/create-work-item/index.ts b/src/features/work-items/create-work-item/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/work-items/create-work-item/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/work-items/create-work-item/schema.ts b/src/features/work-items/create-work-item/schema.ts new file mode 100644 index 0000000..7c2cac9 --- /dev/null +++ b/src/features/work-items/create-work-item/schema.ts @@ -0,0 +1,3 @@ +import { CreateWorkItemSchema } from '../schemas'; + +export { CreateWorkItemSchema }; diff --git a/src/features/work-items/create-work-item/server.test.ts b/src/features/work-items/create-work-item/server.test.ts new file mode 100644 index 0000000..288cb83 --- /dev/null +++ b/src/features/work-items/create-work-item/server.test.ts @@ -0,0 +1,300 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItem } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; + +// Define the types for JsonPatchDocument and JsonPatchOperation +type JsonPatchOperation = { + op: string; + path: string; + value: any; + from?: string; +}; + +type JsonPatchDocument = JsonPatchOperation[]; + +// Mock the azure-devops-node-api +jest.mock('azure-devops-node-api', () => { + return { + WebApi: jest.fn(), + }; +}); + +// Mock the createWorkItem feature +jest.mock('./feature', () => { + // Create mock implementations + const createWorkItemMock = jest.fn(); + + return { + createWorkItem: createWorkItemMock, + }; +}); + +// Import the mocked module +const { createWorkItem } = require('./feature'); + +describe('createWorkItem', () => { + let mockConnection: WebApi; + let mockWorkItemTrackingApi: any; + + beforeEach(() => { + // Reset all mocks + jest.clearAllMocks(); + + // Create mock APIs + mockWorkItemTrackingApi = { + createWorkItem: jest.fn(), + }; + + // Setup the mock connection + mockConnection = { + getWorkItemTrackingApi: jest + .fn() + .mockResolvedValue(mockWorkItemTrackingApi), + } as unknown as WebApi; + }); + + it('should create a work item with required fields', async () => { + // Mock work item response + const mockWorkItem: WorkItem = { + id: 123, + fields: { + 'System.Title': 'Test Work Item', + 'System.Description': 'This is a test work item', + 'System.State': 'New', + }, + url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', + }; + + // Setup mock to return the work item + mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); + + // Setup the mock implementation for this test + createWorkItem.mockImplementationOnce( + async (_: any, projectId: string, workItemType: string, fields: any) => { + // Call the mocked API + const document: JsonPatchDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: fields.title, + }, + { + op: 'add', + path: '/fields/System.Description', + value: fields.description, + }, + ]; + + return await mockWorkItemTrackingApi.createWorkItem( + document, + {}, + projectId, + workItemType, + ); + }, + ); + + // Call createWorkItem + const result = await createWorkItem(mockConnection, 'testproject', 'Task', { + title: 'Test Work Item', + description: 'This is a test work item', + }); + + // Verify the result + expect(result).toEqual(mockWorkItem); + + // Verify the API was called correctly + expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); + + // Verify the document structure + const document: JsonPatchDocument = + mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; + expect(document).toBeInstanceOf(Array); + + // Verify title operation + const titleOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Title', + ); + expect(titleOperation).toBeDefined(); + expect(titleOperation?.op).toBe('add'); + expect(titleOperation?.value).toBe('Test Work Item'); + + // Verify description operation + const descriptionOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Description', + ); + expect(descriptionOperation).toBeDefined(); + expect(descriptionOperation?.op).toBe('add'); + expect(descriptionOperation?.value).toBe('This is a test work item'); + }); + + it('should create a work item with all fields', async () => { + // Mock work item response + const mockWorkItem: WorkItem = { + id: 123, + fields: { + 'System.Title': 'Test Work Item', + 'System.Description': 'This is a test work item', + 'System.State': 'New', + 'System.AssignedTo': 'user@example.com', + 'System.AreaPath': 'testproject\\Team A', + 'System.IterationPath': 'testproject\\Sprint 1', + 'Microsoft.VSTS.Common.Priority': 1, + }, + url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', + }; + + // Setup mock to return the work item + mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); + + // Setup the mock implementation for this test + createWorkItem.mockImplementationOnce( + async (_: any, projectId: string, workItemType: string, fields: any) => { + // Call the mocked API + const document: JsonPatchDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: fields.title, + }, + { + op: 'add', + path: '/fields/System.Description', + value: fields.description, + }, + { + op: 'add', + path: '/fields/System.AssignedTo', + value: fields.assignedTo, + }, + { + op: 'add', + path: '/fields/System.AreaPath', + value: fields.areaPath, + }, + { + op: 'add', + path: '/fields/System.IterationPath', + value: fields.iterationPath, + }, + { + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: fields.priority, + }, + { + op: 'add', + path: '/fields/Custom.Field', + value: fields.additionalFields['Custom.Field'], + }, + ]; + + return await mockWorkItemTrackingApi.createWorkItem( + document, + {}, + projectId, + workItemType, + ); + }, + ); + + // Call createWorkItem with all fields + const result = await createWorkItem(mockConnection, 'testproject', 'Task', { + title: 'Test Work Item', + description: 'This is a test work item', + assignedTo: 'user@example.com', + areaPath: 'testproject\\Team A', + iterationPath: 'testproject\\Sprint 1', + priority: 1, + additionalFields: { + 'Custom.Field': 'Custom Value', + }, + }); + + // Verify the result + expect(result).toEqual(mockWorkItem); + + // Verify the API was called correctly + expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); + + // Verify the document structure + const document: JsonPatchDocument = + mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; + expect(document).toBeInstanceOf(Array); + + // Verify all operations + const titleOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Title', + ); + expect(titleOperation?.value).toBe('Test Work Item'); + + const descriptionOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Description', + ); + expect(descriptionOperation?.value).toBe('This is a test work item'); + + const assignedToOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.AssignedTo', + ); + expect(assignedToOperation?.value).toBe('user@example.com'); + + const areaPathOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.AreaPath', + ); + expect(areaPathOperation?.value).toBe('testproject\\Team A'); + + const iterationPathOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.IterationPath', + ); + expect(iterationPathOperation?.value).toBe('testproject\\Sprint 1'); + + const priorityOperation = document.find( + (op: JsonPatchOperation) => + op.path === '/fields/Microsoft.VSTS.Common.Priority', + ); + expect(priorityOperation?.value).toBe(1); + + const customFieldOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/Custom.Field', + ); + expect(customFieldOperation?.value).toBe('Custom Value'); + }); + + it('should throw AzureDevOpsAuthenticationError when API call fails', async () => { + // Setup mock to throw an error + mockWorkItemTrackingApi.createWorkItem.mockRejectedValueOnce( + new Error('API call failed'), + ); + + // Setup the mock implementation for this test + createWorkItem.mockImplementationOnce(async () => { + throw new Error('Failed to create work item'); + }); + + // Call createWorkItem and expect it to throw + await expect( + createWorkItem(mockConnection, 'testproject', 'Task', { + title: 'Test Work Item', + description: 'This is a test work item', + }), + ).rejects.toThrow('Failed to create work item'); + }); + + it('should throw error when title is missing', async () => { + // Setup the mock implementation for this test + createWorkItem.mockImplementationOnce(async () => { + throw new Error('Title is required'); + }); + + // Call createWorkItem with missing title + await expect( + createWorkItem( + mockConnection, + 'testproject', + 'Task', + { + description: 'This is a test work item', + } as any, // Type assertion to bypass TypeScript check + ), + ).rejects.toThrow('Title is required'); + }); +}); diff --git a/src/features/work-items/get-work-item/feature.test.ts b/src/features/work-items/get-work-item/feature.test.ts new file mode 100644 index 0000000..04cf3f2 --- /dev/null +++ b/src/features/work-items/get-work-item/feature.test.ts @@ -0,0 +1,103 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + WorkItem, + WorkItemExpand, +} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, + AzureDevOpsAuthenticationError, +} from '../../../shared/errors'; +import { getWorkItem } from './feature'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('getWorkItem', () => { + let mockConnection: jest.Mocked; + let mockWorkItemTrackingApi: any; + + const mockWorkItem: WorkItem = { + id: 123, + fields: { + 'System.Title': 'Test Work Item', + 'System.Description': 'A test work item', + 'System.State': 'Active', + }, + url: 'https://dev.azure.com/test/project1/_apis/wit/workItems/123', + }; + + beforeEach(() => { + mockWorkItemTrackingApi = { + getWorkItem: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getWorkItemTrackingApi = jest + .fn() + .mockResolvedValue(mockWorkItemTrackingApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should get a work item by id', async () => { + mockWorkItemTrackingApi.getWorkItem.mockResolvedValue(mockWorkItem); + + const workItem = await getWorkItem(mockConnection, 123); + + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalled(); + expect(workItem).toEqual(mockWorkItem); + }); + + it('should pass expansion options when provided', async () => { + mockWorkItemTrackingApi.getWorkItem.mockResolvedValue(mockWorkItem); + + await getWorkItem(mockConnection, 123, WorkItemExpand.All); + + expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith( + 123, + expect.any(Array), + undefined, + WorkItemExpand.All, + ); + }); + + it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { + mockWorkItemTrackingApi.getWorkItem.mockResolvedValue(null); + + await expect(getWorkItem(mockConnection, 999)).rejects.toThrow( + AzureDevOpsResourceNotFoundError, + ); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockWorkItemTrackingApi.getWorkItem.mockRejectedValue(error); + + await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( + 'Failed to get work item: API error', + ); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsError('Custom error'); + mockWorkItemTrackingApi.getWorkItem.mockRejectedValue(error); + + await expect(getWorkItem(mockConnection, 123)).rejects.toThrow(error); + }); + + // Additional tests from coverage.test.ts + it('should throw AzureDevOpsAuthenticationError when authentication fails', async () => { + mockWorkItemTrackingApi.getWorkItem.mockRejectedValue( + new AzureDevOpsAuthenticationError('Authentication failed'), + ); + + await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); +}); diff --git a/src/features/work-items/get-work-item/feature.ts b/src/features/work-items/get-work-item/feature.ts new file mode 100644 index 0000000..04faa7d --- /dev/null +++ b/src/features/work-items/get-work-item/feature.ts @@ -0,0 +1,53 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { WorkItem } from '../types'; + +/** + * Get a work item by ID + * + * @param connection The Azure DevOps WebApi connection + * @param workItemId The ID of the work item + * @param expand Optional expansion options + * @returns The work item details + * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found + */ +export async function getWorkItem( + connection: WebApi, + workItemId: number, + expand?: WorkItemExpand, +): Promise { + try { + const witApi = await connection.getWorkItemTrackingApi(); + const fields = [ + 'System.Id', + 'System.Title', + 'System.State', + 'System.AssignedTo', + ]; + const workItem = await witApi.getWorkItem( + workItemId, + fields, + undefined, + expand, + ); + + if (!workItem) { + throw new AzureDevOpsResourceNotFoundError( + `Work item '${workItemId}' not found`, + ); + } + + return workItem; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to get work item: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/work-items/get-work-item/index.ts b/src/features/work-items/get-work-item/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/work-items/get-work-item/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/work-items/get-work-item/schema.ts b/src/features/work-items/get-work-item/schema.ts new file mode 100644 index 0000000..4d2f1c2 --- /dev/null +++ b/src/features/work-items/get-work-item/schema.ts @@ -0,0 +1,3 @@ +import { GetWorkItemSchema } from '../schemas'; + +export { GetWorkItemSchema }; diff --git a/src/features/work-items/index.ts b/src/features/work-items/index.ts new file mode 100644 index 0000000..7d6442b --- /dev/null +++ b/src/features/work-items/index.ts @@ -0,0 +1,9 @@ +// Re-export schemas and types +export * from './schemas'; +export * from './types'; + +// Re-export features +export * from './list-work-items'; +export * from './get-work-item'; +export * from './create-work-item'; +export * from './update-work-item'; diff --git a/src/features/work-items/list-work-items/feature.test.ts b/src/features/work-items/list-work-items/feature.test.ts new file mode 100644 index 0000000..1f5adbd --- /dev/null +++ b/src/features/work-items/list-work-items/feature.test.ts @@ -0,0 +1,265 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsAuthenticationError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { listWorkItems } from './feature'; + +// Mock the dependency modules +jest.mock('azure-devops-node-api'); + +const mockWorkItemTrackingApi = { + queryByWiql: jest.fn(), + getWorkItems: jest.fn(), + queryById: jest.fn(), +}; + +// Create the mock web API with a jest function to allow mockRejectedValueOnce +const getWorkItemTrackingApiFn = jest + .fn() + .mockResolvedValue(mockWorkItemTrackingApi); +const mockWebApi = { + getWorkItemTrackingApi: getWorkItemTrackingApiFn, +} as unknown as WebApi; + +describe('listWorkItems', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return work items using WIQL query', async () => { + // Mock the query result from the API + (mockWorkItemTrackingApi.queryByWiql as jest.Mock).mockResolvedValueOnce({ + workItems: [{ id: 1 }, { id: 2 }], + }); + + // Mock the work items data + (mockWorkItemTrackingApi.getWorkItems as jest.Mock).mockResolvedValueOnce([ + { id: 1, fields: { 'System.Title': 'First work item' } }, + { id: 2, fields: { 'System.Title': 'Second work item' } }, + ]); + + const result = await listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }); + + // Check the results match what was returned from the API + expect(result).toEqual([ + { id: 1, fields: { 'System.Title': 'First work item' } }, + { id: 2, fields: { 'System.Title': 'Second work item' } }, + ]); + + // Verify that the correct API calls were made + expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith( + { query: 'SELECT * FROM WorkItems' }, + { project: 'project', team: undefined }, + ); + expect(mockWorkItemTrackingApi.getWorkItems).toHaveBeenCalledWith( + [1, 2], + ['System.Id', 'System.Title', 'System.State', 'System.AssignedTo'], + undefined, + 4, + ); + }); + + it('should handle empty query results', async () => { + // Setup mock responses for empty query results + mockWorkItemTrackingApi.queryByWiql.mockResolvedValue({ + workItems: [], + }); + + // Call the function + const result = await listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems WHERE [System.Id] = -1', // Query that would return no items + }); + + // Verify the results are empty + expect(result).toEqual([]); + + // Verify that getWorkItems was not called since there were no IDs to fetch + expect(mockWorkItemTrackingApi.getWorkItems).not.toHaveBeenCalled(); + }); + + it('should use saved query when queryId is provided', async () => { + // Setup mock responses + const mockQueryResult = { + workItems: [{ id: 3 }, { id: 4 }], + }; + + const mockWorkItems = [ + { id: 3, fields: { 'System.Title': 'Work Item 3' } }, + { id: 4, fields: { 'System.Title': 'Work Item 4' } }, + ]; + + mockWorkItemTrackingApi.queryById.mockResolvedValue(mockQueryResult); + mockWorkItemTrackingApi.getWorkItems.mockResolvedValue(mockWorkItems); + + // Call the function + const result = await listWorkItems(mockWebApi, { + projectId: 'project', + queryId: 'query-1234', + }); + + // Verify the results + expect(result).toEqual(mockWorkItems); + expect(mockWorkItemTrackingApi.queryById).toHaveBeenCalledWith( + 'query-1234', + { project: 'project', team: undefined }, + ); + }); + + it('should handle pagination with top and skip parameters', async () => { + // Setup mock responses + const mockQueryResult = { + workItems: Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })), + }; + + const mockWorkItems = [ + { id: 11, fields: { 'System.Title': 'Work Item 11' } }, + { id: 12, fields: { 'System.Title': 'Work Item 12' } }, + { id: 13, fields: { 'System.Title': 'Work Item 13' } }, + { id: 14, fields: { 'System.Title': 'Work Item 14' } }, + { id: 15, fields: { 'System.Title': 'Work Item 15' } }, + ]; + + mockWorkItemTrackingApi.queryByWiql.mockResolvedValue(mockQueryResult); + mockWorkItemTrackingApi.getWorkItems.mockResolvedValue(mockWorkItems); + + // Call the function with pagination parameters + const result = await listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + top: 5, + skip: 10, + }); + + // Verify the results + expect(result).toEqual(mockWorkItems); + expect(mockWorkItemTrackingApi.getWorkItems).toHaveBeenCalledWith( + [11, 12, 13, 14, 15], // Skip 10, take 5 + expect.any(Array), + undefined, + expect.any(Number), + ); + }); + + it('should handle empty work items array from getWorkItems', async () => { + // Setup mock responses + mockWorkItemTrackingApi.queryByWiql.mockResolvedValue({ + workItems: [{ id: 1 }, { id: 2 }], + }); + + // Mock getWorkItems to return an empty array (unusual but could happen) + mockWorkItemTrackingApi.getWorkItems.mockResolvedValue([]); + + // Call the function + const result = await listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }); + + // Verify the results are empty + expect(result).toEqual([]); + }); + + it('should throw AzureDevOpsAuthenticationError when authentication fails', async () => { + // Mock an authentication error + getWorkItemTrackingApiFn.mockRejectedValueOnce( + new AzureDevOpsAuthenticationError('Authentication failed'), + ); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsAuthenticationError); + }); + + it('should throw AzureDevOpsResourceNotFoundError when project is not found', async () => { + // Mock a project not found error + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( + new AzureDevOpsResourceNotFoundError('Project not found'), + ); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'non-existent', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsResourceNotFoundError); + }); + + it('should convert generic Error with "not found" message to AzureDevOpsResourceNotFoundError', async () => { + // Mock a generic error with "not found" in the message + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( + new Error('The specified project was not found') + ); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsResourceNotFoundError); + }); + + it('should convert generic Error with "does not exist" message to AzureDevOpsResourceNotFoundError', async () => { + // Mock a generic error with "does not exist" in the message + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( + new Error('The project does not exist') + ); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsResourceNotFoundError); + }); + + it('should convert generic Error with "Unauthorized" message to AzureDevOpsAuthenticationError', async () => { + // Mock a generic error with "Unauthorized" in the message + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( + new Error('Unauthorized access to the project') + ); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsAuthenticationError); + }); + + it('should wrap non-Error objects in AzureDevOpsError', async () => { + // Mock a string error (not an Error instance) + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce('String error message'); + + // Call the function and expect it to throw + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow(AzureDevOpsError); + + // Reset the mock for the second test + mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce('String error message'); + + await expect( + listWorkItems(mockWebApi, { + projectId: 'project', + wiql: 'SELECT * FROM WorkItems', + }), + ).rejects.toThrow('Failed to list work items: String error message'); + }); +}); diff --git a/src/features/work-items/list-work-items/feature.ts b/src/features/work-items/list-work-items/feature.ts new file mode 100644 index 0000000..7036427 --- /dev/null +++ b/src/features/work-items/list-work-items/feature.ts @@ -0,0 +1,126 @@ +import { WebApi } from 'azure-devops-node-api'; +import { TeamContext } from 'azure-devops-node-api/interfaces/CoreInterfaces'; +import { + WorkItem, + WorkItemExpand, + WorkItemReference, +} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { + AzureDevOpsError, + AzureDevOpsAuthenticationError, + AzureDevOpsResourceNotFoundError, +} from '../../../shared/errors'; +import { ListWorkItemsOptions, WorkItem as WorkItemType } from '../types'; + +/** + * Constructs the default WIQL query for listing work items + */ +function constructDefaultWiql(projectId: string, teamId?: string): string { + let query = `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '${projectId}'`; + if (teamId) { + query += ` AND [System.TeamId] = '${teamId}'`; + } + query += ' ORDER BY [System.Id]'; + return query; +} + +/** + * List work items in a project + * + * @param connection The Azure DevOps WebApi connection + * @param options Options for listing work items + * @returns List of work items + */ +export async function listWorkItems( + connection: WebApi, + options: ListWorkItemsOptions, +): Promise { + try { + const witApi = await connection.getWorkItemTrackingApi(); + const { projectId, teamId, queryId, wiql } = options; + + let workItemRefs: WorkItemReference[] = []; + + if (queryId) { + const teamContext: TeamContext = { + project: projectId, + team: teamId, + }; + const queryResult = await witApi.queryById(queryId, teamContext); + workItemRefs = queryResult.workItems || []; + } else { + const query = wiql || constructDefaultWiql(projectId, teamId); + const teamContext: TeamContext = { + project: projectId, + team: teamId, + }; + const queryResult = await witApi.queryByWiql({ query }, teamContext); + workItemRefs = queryResult.workItems || []; + } + + // Apply pagination in memory + const { top, skip } = options; + if (skip !== undefined) { + workItemRefs = workItemRefs.slice(skip); + } + if (top !== undefined) { + workItemRefs = workItemRefs.slice(0, top); + } + + const workItemIds = workItemRefs + .map((ref) => ref.id) + .filter((id): id is number => id !== undefined); + + if (workItemIds.length === 0) { + return []; + } + + const fields = [ + 'System.Id', + 'System.Title', + 'System.State', + 'System.AssignedTo', + ]; + const workItems = await witApi.getWorkItems( + workItemIds, + fields, + undefined, + WorkItemExpand.All, + ); + + if (!workItems) { + return []; + } + + return workItems.filter((wi): wi is WorkItem => wi !== undefined); + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + + // Check for specific error types and convert to appropriate Azure DevOps errors + if (error instanceof Error) { + if ( + error.message.includes('Authentication') || + error.message.includes('Unauthorized') + ) { + throw new AzureDevOpsAuthenticationError( + `Failed to authenticate: ${error.message}`, + ); + } + + if ( + error.message.includes('not found') || + error.message.includes('does not exist') + ) { + throw new AzureDevOpsResourceNotFoundError( + `Resource not found: ${error.message}`, + ); + } + } + + throw new AzureDevOpsError( + `Failed to list work items: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/work-items/list-work-items/index.ts b/src/features/work-items/list-work-items/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/work-items/list-work-items/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/tests/unit/operations/workitems/list-work-items.test.ts b/src/features/work-items/list-work-items/operations.test.ts similarity index 87% rename from tests/unit/operations/workitems/list-work-items.test.ts rename to src/features/work-items/list-work-items/operations.test.ts index f7f7280..fb5e607 100644 --- a/tests/unit/operations/workitems/list-work-items.test.ts +++ b/src/features/work-items/list-work-items/operations.test.ts @@ -1,12 +1,12 @@ import { WebApi } from 'azure-devops-node-api'; import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; -import { - WorkItem, +import { + WorkItem, WorkItemQueryResult, - WorkItemExpand + WorkItemExpand, } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { listWorkItems } from '../../../../src/operations/workitems'; -import { AzureDevOpsResourceNotFoundError } from '../../../../src/common/errors'; +import { listWorkItems } from './feature'; +import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; // Mock the Azure DevOps WebApi and handler jest.mock('azure-devops-node-api'); @@ -25,8 +25,13 @@ describe('List Work Items Operation', () => { // Create a mock handler and WebApi const mockHandler = getPersonalAccessTokenHandler('fake-token'); - mockConnection = new WebApi('https://dev.azure.com/organization', mockHandler) as jest.Mocked; - mockConnection.getWorkItemTrackingApi = jest.fn().mockResolvedValue(mockWitApi); + mockConnection = new WebApi( + 'https://dev.azure.com/organization', + mockHandler, + ) as jest.Mocked; + mockConnection.getWorkItemTrackingApi = jest + .fn() + .mockResolvedValue(mockWitApi); }); afterEach(() => { @@ -56,8 +61,14 @@ describe('List Work Items Operation', () => { const mockQueryResult: WorkItemQueryResult = { workItems: [ - { id: 123, url: 'https://dev.azure.com/org/project/_apis/wit/workItems/123' }, - { id: 124, url: 'https://dev.azure.com/org/project/_apis/wit/workItems/124' }, + { + id: 123, + url: 'https://dev.azure.com/org/project/_apis/wit/workItems/123', + }, + { + id: 124, + url: 'https://dev.azure.com/org/project/_apis/wit/workItems/124', + }, ], }; @@ -85,7 +96,7 @@ describe('List Work Items Operation', () => { ); expect(mockWitApi.getWorkItems).toHaveBeenCalledWith( [123, 124], - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], + ['System.Id', 'System.Title', 'System.State', 'System.AssignedTo'], undefined, WorkItemExpand.All, ); @@ -157,13 +168,10 @@ describe('List Work Items Operation', () => { // Assert expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.queryById).toHaveBeenCalledWith( - options.queryId, - { - project: options.projectId, - team: undefined, - }, - ); + expect(mockWitApi.queryById).toHaveBeenCalledWith(options.queryId, { + project: options.projectId, + team: undefined, + }); expect(mockWitApi.getWorkItems).toHaveBeenCalled(); expect(result).toEqual(mockWorkItems); }); @@ -187,7 +195,7 @@ describe('List Work Items Operation', () => { expect(mockWitApi.queryByWiql).toHaveBeenCalled(); expect(mockWitApi.getWorkItems).toHaveBeenCalledWith( [123], // Only the first ID should be used due to top: 1 - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], + ['System.Id', 'System.Title', 'System.State', 'System.AssignedTo'], undefined, WorkItemExpand.All, ); @@ -211,7 +219,7 @@ describe('List Work Items Operation', () => { expect(mockWitApi.queryByWiql).toHaveBeenCalled(); expect(mockWitApi.getWorkItems).toHaveBeenCalledWith( [124], // Only the second ID should be used due to skip: 1 - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], + ['System.Id', 'System.Title', 'System.State', 'System.AssignedTo'], undefined, WorkItemExpand.All, ); @@ -223,7 +231,7 @@ describe('List Work Items Operation', () => { it('should handle empty query results', async () => { // Arrange mockWitApi.queryByWiql.mockResolvedValue({ workItems: [] }); - + const options = { projectId: 'project-id', wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', @@ -242,7 +250,7 @@ describe('List Work Items Operation', () => { it('should wrap errors', async () => { // Arrange mockWitApi.queryByWiql.mockRejectedValue(new Error('API error')); - + const options = { projectId: 'project-id', wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', @@ -250,7 +258,7 @@ describe('List Work Items Operation', () => { // Act & Assert await expect(listWorkItems(mockConnection, options)).rejects.toThrow( - 'Failed to list work items: API error' + 'Failed to list work items: API error', ); }); @@ -258,7 +266,7 @@ describe('List Work Items Operation', () => { // Arrange const error = new AzureDevOpsResourceNotFoundError('Project not found'); mockWitApi.queryByWiql.mockRejectedValue(error); - + const options = { projectId: 'project-id', wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', @@ -266,8 +274,8 @@ describe('List Work Items Operation', () => { // Act & Assert await expect(listWorkItems(mockConnection, options)).rejects.toThrow( - AzureDevOpsResourceNotFoundError + AzureDevOpsResourceNotFoundError, ); }); }); -}); \ No newline at end of file +}); diff --git a/src/features/work-items/list-work-items/schema.ts b/src/features/work-items/list-work-items/schema.ts new file mode 100644 index 0000000..ae4812d --- /dev/null +++ b/src/features/work-items/list-work-items/schema.ts @@ -0,0 +1,3 @@ +import { ListWorkItemsSchema } from '../schemas'; + +export { ListWorkItemsSchema }; diff --git a/tests/unit/server-list-work-items.test.ts b/src/features/work-items/list-work-items/server.test.ts similarity index 71% rename from tests/unit/server-list-work-items.test.ts rename to src/features/work-items/list-work-items/server.test.ts index f538cc4..e6454ec 100644 --- a/tests/unit/server-list-work-items.test.ts +++ b/src/features/work-items/list-work-items/server.test.ts @@ -3,21 +3,21 @@ const mockWebApiConstructor = jest.fn().mockImplementation(() => { return { getWorkItemTrackingApi: jest.fn().mockResolvedValue({ queryById: jest.fn().mockResolvedValue({ - workItems: [] + workItems: [], }), queryByWiql: jest.fn().mockResolvedValue({ - workItems: [] + workItems: [], }), - getWorkItems: jest.fn().mockResolvedValue([]) + getWorkItems: jest.fn().mockResolvedValue([]), }), getLocationsApi: jest.fn().mockResolvedValue({ - getResourceAreas: jest.fn().mockResolvedValue([]) + getResourceAreas: jest.fn().mockResolvedValue([]), }), getCoreApi: jest.fn().mockResolvedValue({ getProjects: jest.fn().mockResolvedValue([]), - getProject: jest.fn().mockResolvedValue({}) + getProject: jest.fn().mockResolvedValue({}), }), - getGitApi: jest.fn().mockResolvedValue({}) + getGitApi: jest.fn().mockResolvedValue({}), }; }); @@ -27,16 +27,16 @@ const mockGetBearerHandler = jest.fn(); jest.mock('azure-devops-node-api', () => ({ WebApi: mockWebApiConstructor, getPersonalAccessTokenHandler: mockGetPersonalAccessTokenHandler, - getBearerHandler: mockGetBearerHandler + getBearerHandler: mockGetBearerHandler, })); // Mock the server module to avoid authentication errors -jest.mock('../../src/server', () => { - const originalModule = jest.requireActual('../../src/server'); - +jest.mock('../../../server', () => { + const originalModule = jest.requireActual('../../../server'); + return { ...originalModule, - testConnection: jest.fn().mockResolvedValue(true) + testConnection: jest.fn().mockResolvedValue(true), }; }); @@ -45,44 +45,32 @@ class MockServerClass { setRequestHandler = jest.fn(); registerTool = jest.fn(); capabilities = { - tools: {} as Record + tools: {} as Record, }; } // Mock the MCP SDK modules jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ - Server: jest.fn().mockImplementation(() => new MockServerClass()) + Server: jest.fn().mockImplementation(() => new MockServerClass()), })); jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ ListToolsRequestSchema: 'ListToolsRequestSchema', - CallToolRequestSchema: 'CallToolRequestSchema' + CallToolRequestSchema: 'CallToolRequestSchema', })); // Now import modules -import { z } from 'zod'; -import { AzureDevOpsError } from '../../src/common/errors'; -import { AuthenticationMethod } from '../../src/auth'; - -// Define schema objects -const ListWorkItemsSchema = z.object({ - projectId: z.string(), - teamId: z.string().optional(), - queryId: z.string().optional(), - wiql: z.string().optional(), - top: z.number().optional(), - skip: z.number().optional(), -}); +import { AzureDevOpsError } from '../../../shared/errors'; +import { AuthenticationMethod } from '../../../shared/auth'; -// Mock the workitems module -jest.mock('../../src/operations/workitems', () => ({ - ListWorkItemsSchema, +// Mock the work-items feature module +jest.mock('./feature', () => ({ listWorkItems: jest.fn(), })); // Import the mocked modules -import { listWorkItems } from '../../src/operations/workitems'; -import { createAzureDevOpsServer } from '../../src/server'; +import { listWorkItems } from './feature'; +import { createAzureDevOpsServer } from '../../../server'; describe('Server - list_work_items Tool', () => { let mockServer: MockServerClass; @@ -90,23 +78,25 @@ describe('Server - list_work_items Tool', () => { beforeEach(() => { jest.clearAllMocks(); - + // Initialize the mock server mockServer = new MockServerClass(); - + // Mock the Server constructor to return our mockServer - (require('@modelcontextprotocol/sdk/server/index.js').Server as jest.Mock).mockReturnValue(mockServer); - + ( + require('@modelcontextprotocol/sdk/server/index.js').Server as jest.Mock + ).mockReturnValue(mockServer); + // Create server instance with minimal config createAzureDevOpsServer({ authMethod: AuthenticationMethod.PersonalAccessToken, personalAccessToken: 'fake-pat', organizationUrl: 'https://dev.azure.com/fake-org', }); - + // Get the CallToolRequestSchema handler callToolHandler = mockServer.setRequestHandler.mock.calls.find( - (call: any[]) => call[0] === 'CallToolRequestSchema' + (call: any[]) => call[0] === 'CallToolRequestSchema', )?.[1]; }); @@ -117,7 +107,7 @@ describe('Server - list_work_items Tool', () => { { id: 456, fields: { 'System.Title': 'Work Item 2' } }, ]; (listWorkItems as jest.Mock).mockResolvedValueOnce(mockWorkItems); - + // Call the handler with list_work_items tool const result = await callToolHandler({ params: { @@ -128,7 +118,7 @@ describe('Server - list_work_items Tool', () => { }, }, }); - + // Verify the result expect(result).toEqual({ content: [ @@ -138,15 +128,12 @@ describe('Server - list_work_items Tool', () => { }, ], }); - + // Verify the listWorkItems function was called with correct parameters - expect(listWorkItems).toHaveBeenCalledWith( - expect.anything(), - { - projectId: 'project1', - wiql: 'SELECT [System.Id] FROM WorkItems', - } - ); + expect(listWorkItems).toHaveBeenCalledWith(expect.anything(), { + projectId: 'project1', + wiql: 'SELECT [System.Id] FROM WorkItems', + }); }); it('should handle validation errors', async () => { @@ -155,7 +142,7 @@ describe('Server - list_work_items Tool', () => { // This won't be called because the zod validation will fail return Promise.resolve([]); }); - + // Call the handler with invalid arguments (missing required projectId) const result = await callToolHandler({ params: { @@ -166,7 +153,7 @@ describe('Server - list_work_items Tool', () => { }, }, }); - + // Verify the result contains an error message expect(result.content[0].text).toContain('Required'); }); @@ -174,9 +161,9 @@ describe('Server - list_work_items Tool', () => { it('should handle API errors', async () => { // Mock the listWorkItems function to throw an error (listWorkItems as jest.Mock).mockRejectedValueOnce( - new AzureDevOpsError('API error') + new AzureDevOpsError('API error'), ); - + // Call the handler const result = await callToolHandler({ params: { @@ -186,8 +173,8 @@ describe('Server - list_work_items Tool', () => { }, }, }); - + // Verify the result contains an error message expect(result.content[0].text).toContain('API error'); }); -}); \ No newline at end of file +}); diff --git a/src/features/work-items/schemas.ts b/src/features/work-items/schemas.ts new file mode 100644 index 0000000..6d441e9 --- /dev/null +++ b/src/features/work-items/schemas.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +/** + * Schema for getting a work item + */ +export const GetWorkItemSchema = z.object({ + workItemId: z.number().describe('The ID of the work item'), +}); + +/** + * Schema for listing work items + */ +export const ListWorkItemsSchema = z.object({ + projectId: z.string().describe('The ID or name of the project'), + teamId: z.string().optional().describe('The ID of the team'), + queryId: z.string().optional().describe('ID of a saved work item query'), + wiql: z.string().optional().describe('Work Item Query Language (WIQL) query'), + top: z.number().optional().describe('Maximum number of work items to return'), + skip: z.number().optional().describe('Number of work items to skip'), +}); + +/** + * Schema for creating a work item + */ +export const CreateWorkItemSchema = z.object({ + projectId: z.string().describe('The ID or name of the project'), + workItemType: z + .string() + .describe( + 'The type of work item to create (e.g., "Task", "Bug", "User Story")', + ), + title: z.string().describe('The title of the work item'), + description: z + .string() + .optional() + .describe('The description of the work item'), + assignedTo: z + .string() + .optional() + .describe('The email or name of the user to assign the work item to'), + areaPath: z.string().optional().describe('The area path for the work item'), + iterationPath: z + .string() + .optional() + .describe('The iteration path for the work item'), + priority: z.number().optional().describe('The priority of the work item'), + additionalFields: z + .record(z.string(), z.any()) + .optional() + .describe('Additional fields to set on the work item'), +}); + +/** + * Schema for updating a work item + */ +export const UpdateWorkItemSchema = z.object({ + workItemId: z.number().describe('The ID of the work item to update'), + title: z.string().optional().describe('The updated title of the work item'), + description: z + .string() + .optional() + .describe('The updated description of the work item'), + assignedTo: z + .string() + .optional() + .describe('The email or name of the user to assign the work item to'), + areaPath: z + .string() + .optional() + .describe('The updated area path for the work item'), + iterationPath: z + .string() + .optional() + .describe('The updated iteration path for the work item'), + priority: z + .number() + .optional() + .describe('The updated priority of the work item'), + state: z.string().optional().describe('The updated state of the work item'), + additionalFields: z + .record(z.string(), z.any()) + .optional() + .describe('Additional fields to update on the work item'), +}); diff --git a/src/features/work-items/types.ts b/src/features/work-items/types.ts new file mode 100644 index 0000000..903a42c --- /dev/null +++ b/src/features/work-items/types.ts @@ -0,0 +1,46 @@ +import { + WorkItem, + WorkItemReference, +} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; + +/** + * Options for listing work items + */ +export interface ListWorkItemsOptions { + projectId: string; + teamId?: string; + queryId?: string; + wiql?: string; + top?: number; + skip?: number; +} + +/** + * Options for creating a work item + */ +export interface CreateWorkItemOptions { + title: string; + description?: string; + assignedTo?: string; + areaPath?: string; + iterationPath?: string; + priority?: number; + additionalFields?: Record; +} + +/** + * Options for updating a work item + */ +export interface UpdateWorkItemOptions { + title?: string; + description?: string; + assignedTo?: string; + areaPath?: string; + iterationPath?: string; + priority?: number; + state?: string; + additionalFields?: Record; +} + +// Re-export WorkItem and WorkItemReference types for convenience +export type { WorkItem, WorkItemReference }; diff --git a/src/features/work-items/update-work-item/feature.test.ts b/src/features/work-items/update-work-item/feature.test.ts new file mode 100644 index 0000000..be14198 --- /dev/null +++ b/src/features/work-items/update-work-item/feature.test.ts @@ -0,0 +1,189 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + WorkItem, + WorkItemExpand, +} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { updateWorkItem } from './feature'; +import { UpdateWorkItemOptions } from '../types'; + +// Mock WebApi +jest.mock('azure-devops-node-api'); + +describe('updateWorkItem', () => { + let mockConnection: jest.Mocked; + let mockWitApi: any; + + const mockWorkItem: WorkItem = { + id: 123, + rev: 2, + fields: { + 'System.Id': 123, + 'System.Title': 'Updated Work Item', + 'System.State': 'Active', + }, + url: 'https://dev.azure.com/test/project/_apis/wit/workItems/123', + }; + + beforeEach(() => { + mockWitApi = { + updateWorkItem: jest.fn(), + }; + + // @ts-ignore - Ignoring type checking for the mock + mockConnection = new WebApi('', {}); + mockConnection.getWorkItemTrackingApi = jest + .fn() + .mockResolvedValue(mockWitApi); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should update a work item with the provided fields', async () => { + mockWitApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + const options: UpdateWorkItemOptions = { + title: 'Updated Work Item', + state: 'Active', + }; + + const workItem = await updateWorkItem(mockConnection, 123, options); + + expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled(); + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + [ + { + op: 'add', + path: '/fields/System.Title', + value: 'Updated Work Item', + }, + { + op: 'add', + path: '/fields/System.State', + value: 'Active', + }, + ], + 123, + undefined, + false, + false, + false, + WorkItemExpand.All, + ); + + expect(workItem).toEqual(mockWorkItem); + }); + + it('should include all optional fields when provided', async () => { + mockWitApi.updateWorkItem.mockResolvedValue(mockWorkItem); + + const options: UpdateWorkItemOptions = { + title: 'Updated Work Item', + description: 'Updated Description', + assignedTo: 'user@example.com', + areaPath: 'TestProject\\Area', + iterationPath: 'TestProject\\Iteration1', + priority: 1, + state: 'Active', + additionalFields: { + 'Custom.Field': 'Custom Value', + }, + }; + + await updateWorkItem(mockConnection, 123, options); + + const expectedDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: 'Updated Work Item', + }, + { + op: 'add', + path: '/fields/System.Description', + value: 'Updated Description', + }, + { + op: 'add', + path: '/fields/System.AssignedTo', + value: 'user@example.com', + }, + { + op: 'add', + path: '/fields/System.AreaPath', + value: 'TestProject\\Area', + }, + { + op: 'add', + path: '/fields/System.IterationPath', + value: 'TestProject\\Iteration1', + }, + { + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: 1, + }, + { + op: 'add', + path: '/fields/System.State', + value: 'Active', + }, + { + op: 'add', + path: '/fields/Custom.Field', + value: 'Custom Value', + }, + ]; + + expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( + {}, + expectedDocument, + 123, + undefined, + false, + false, + false, + WorkItemExpand.All, + ); + }); + + it('should throw an error when no fields are provided for update', async () => { + await expect(updateWorkItem(mockConnection, 123, {})).rejects.toThrow( + 'At least one field must be provided for update', + ); + }); + + it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { + mockWitApi.updateWorkItem.mockResolvedValue(null); + + await expect( + updateWorkItem(mockConnection, 123, { title: 'Updated Work Item' }), + ).rejects.toThrow( + new AzureDevOpsResourceNotFoundError("Work item '123' not found"), + ); + }); + + it('should throw an error when the API call fails', async () => { + const error = new Error('API error'); + mockWitApi.updateWorkItem.mockRejectedValue(error); + + await expect( + updateWorkItem(mockConnection, 123, { title: 'Updated Work Item' }), + ).rejects.toThrow('Failed to update work item: API error'); + }); + + it('should pass through AzureDevOpsError', async () => { + const error = new AzureDevOpsError('Custom error'); + mockWitApi.updateWorkItem.mockRejectedValue(error); + + await expect( + updateWorkItem(mockConnection, 123, { title: 'Updated Work Item' }), + ).rejects.toThrow(error); + }); +}); diff --git a/src/features/work-items/update-work-item/feature.ts b/src/features/work-items/update-work-item/feature.ts new file mode 100644 index 0000000..0b83900 --- /dev/null +++ b/src/features/work-items/update-work-item/feature.ts @@ -0,0 +1,129 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { + AzureDevOpsResourceNotFoundError, + AzureDevOpsError, +} from '../../../shared/errors'; +import { UpdateWorkItemOptions, WorkItem } from '../types'; + +/** + * Update a work item + * + * @param connection The Azure DevOps WebApi connection + * @param workItemId The ID of the work item to update + * @param options Options for updating the work item + * @returns The updated work item + * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found + */ +export async function updateWorkItem( + connection: WebApi, + workItemId: number, + options: UpdateWorkItemOptions, +): Promise { + try { + const witApi = await connection.getWorkItemTrackingApi(); + + // Create the JSON patch document + const document = []; + + // Add optional fields if provided + if (options.title) { + document.push({ + op: 'add', + path: '/fields/System.Title', + value: options.title, + }); + } + + if (options.description) { + document.push({ + op: 'add', + path: '/fields/System.Description', + value: options.description, + }); + } + + if (options.assignedTo) { + document.push({ + op: 'add', + path: '/fields/System.AssignedTo', + value: options.assignedTo, + }); + } + + if (options.areaPath) { + document.push({ + op: 'add', + path: '/fields/System.AreaPath', + value: options.areaPath, + }); + } + + if (options.iterationPath) { + document.push({ + op: 'add', + path: '/fields/System.IterationPath', + value: options.iterationPath, + }); + } + + if (options.priority) { + document.push({ + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: options.priority, + }); + } + + if (options.state) { + document.push({ + op: 'add', + path: '/fields/System.State', + value: options.state, + }); + } + + // Add any additional fields + if (options.additionalFields) { + for (const [key, value] of Object.entries(options.additionalFields)) { + document.push({ + op: 'add', + path: `/fields/${key}`, + value: value, + }); + } + } + + // If no fields to update, throw an error + if (document.length === 0) { + throw new Error('At least one field must be provided for update'); + } + + // Update the work item + const updatedWorkItem = await witApi.updateWorkItem( + {}, // customHeaders + document, + workItemId, + undefined, // project + false, // validateOnly + false, // bypassRules + false, // suppressNotifications + WorkItemExpand.All, // expand + ); + + if (!updatedWorkItem) { + throw new AzureDevOpsResourceNotFoundError( + `Work item '${workItemId}' not found`, + ); + } + + return updatedWorkItem; + } catch (error) { + if (error instanceof AzureDevOpsError) { + throw error; + } + throw new Error( + `Failed to update work item: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/features/work-items/update-work-item/index.ts b/src/features/work-items/update-work-item/index.ts new file mode 100644 index 0000000..52b8562 --- /dev/null +++ b/src/features/work-items/update-work-item/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './feature'; diff --git a/src/features/work-items/update-work-item/schema.ts b/src/features/work-items/update-work-item/schema.ts new file mode 100644 index 0000000..fb75326 --- /dev/null +++ b/src/features/work-items/update-work-item/schema.ts @@ -0,0 +1,3 @@ +import { UpdateWorkItemSchema } from '../schemas'; + +export { UpdateWorkItemSchema }; diff --git a/tests/unit/operations/workitems/update-work-item.test.ts b/src/features/work-items/update-work-item/server.test.ts similarity index 50% rename from tests/unit/operations/workitems/update-work-item.test.ts rename to src/features/work-items/update-work-item/server.test.ts index 7d0eff2..13fdeb2 100644 --- a/tests/unit/operations/workitems/update-work-item.test.ts +++ b/src/features/work-items/update-work-item/server.test.ts @@ -1,8 +1,6 @@ import { WebApi } from 'azure-devops-node-api'; -import { - WorkItem, -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { AzureDevOpsResourceNotFoundError } from '../../../../src/common/errors'; +import { WorkItem } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors'; // Define the types for JsonPatchDocument and JsonPatchOperation type JsonPatchOperation = { @@ -21,39 +19,40 @@ jest.mock('azure-devops-node-api', () => { }; }); -// Mock the workitems module -jest.mock('../../../../src/operations/workitems', () => { +// Mock the updateWorkItem feature +jest.mock('./feature', () => { // Create mock implementations const updateWorkItemMock = jest.fn(); - + return { updateWorkItem: updateWorkItemMock, }; }); // Import the mocked module -const workitemsModule = require('../../../../src/operations/workitems'); -const updateWorkItem = workitemsModule.updateWorkItem; +const { updateWorkItem } = require('./feature'); describe('updateWorkItem', () => { let mockConnection: WebApi; let mockWorkItemTrackingApi: any; - + beforeEach(() => { // Reset all mocks jest.clearAllMocks(); - + // Create mock APIs mockWorkItemTrackingApi = { updateWorkItem: jest.fn(), }; - + // Setup the mock connection mockConnection = { - getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi), + getWorkItemTrackingApi: jest + .fn() + .mockResolvedValue(mockWorkItemTrackingApi), } as unknown as WebApi; }); - + it('should update a work item with basic fields', async () => { // Mock work item response const mockWorkItem: WorkItem = { @@ -65,62 +64,68 @@ describe('updateWorkItem', () => { }, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', }; - + // Setup mock to return the work item mockWorkItemTrackingApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - + // Setup the mock implementation for this test - updateWorkItem.mockImplementationOnce(async (_: any, workItemId: number, fields: any) => { - // Call the mocked API - const document: JsonPatchDocument = [ - { - op: 'add', - path: '/fields/System.Title', - value: fields.title - }, - { - op: 'add', - path: '/fields/System.Description', - value: fields.description - } - ]; - - return await mockWorkItemTrackingApi.updateWorkItem(document, workItemId); - }); - - // Call updateWorkItem - const result = await updateWorkItem( - mockConnection, - 123, - { - title: 'Updated Work Item', - description: 'This is an updated work item', - } + updateWorkItem.mockImplementationOnce( + async (_: any, workItemId: number, fields: any) => { + // Call the mocked API + const document: JsonPatchDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: fields.title, + }, + { + op: 'add', + path: '/fields/System.Description', + value: fields.description, + }, + ]; + + return await mockWorkItemTrackingApi.updateWorkItem( + document, + workItemId, + ); + }, ); - + + // Call updateWorkItem + const result = await updateWorkItem(mockConnection, 123, { + title: 'Updated Work Item', + description: 'This is an updated work item', + }); + // Verify the result expect(result).toEqual(mockWorkItem); - + // Verify the API was called correctly expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledTimes(1); - + // Verify the document structure - const document: JsonPatchDocument = mockWorkItemTrackingApi.updateWorkItem.mock.calls[0][0]; + const document: JsonPatchDocument = + mockWorkItemTrackingApi.updateWorkItem.mock.calls[0][0]; expect(document).toBeInstanceOf(Array); - + // Verify title operation - const titleOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Title'); + const titleOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Title', + ); expect(titleOperation).toBeDefined(); expect(titleOperation?.op).toBe('add'); expect(titleOperation?.value).toBe('Updated Work Item'); - + // Verify description operation - const descriptionOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Description'); + const descriptionOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Description', + ); expect(descriptionOperation).toBeDefined(); expect(descriptionOperation?.op).toBe('add'); expect(descriptionOperation?.value).toBe('This is an updated work item'); }); - + it('should update a work item with all fields', async () => { // Mock work item response const mockWorkItem: WorkItem = { @@ -136,154 +141,173 @@ describe('updateWorkItem', () => { }, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', }; - + // Setup mock to return the work item mockWorkItemTrackingApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - + // Setup the mock implementation for this test - updateWorkItem.mockImplementationOnce(async (_: any, workItemId: number, fields: any) => { - // Call the mocked API - const document: JsonPatchDocument = [ - { - op: 'add', - path: '/fields/System.Title', - value: fields.title - }, - { - op: 'add', - path: '/fields/System.Description', - value: fields.description - }, - { - op: 'add', - path: '/fields/System.AssignedTo', - value: fields.assignedTo - }, - { - op: 'add', - path: '/fields/System.AreaPath', - value: fields.areaPath - }, - { - op: 'add', - path: '/fields/System.IterationPath', - value: fields.iterationPath - }, - { - op: 'add', - path: '/fields/Microsoft.VSTS.Common.Priority', - value: fields.priority - }, - { - op: 'add', - path: '/fields/System.State', - value: fields.state - }, - { - op: 'add', - path: '/fields/Custom.Field', - value: fields.additionalFields['Custom.Field'] - } - ]; - - return await mockWorkItemTrackingApi.updateWorkItem(document, workItemId); - }); - - // Call updateWorkItem with all fields - const result = await updateWorkItem( - mockConnection, - 123, - { - title: 'Updated Work Item', - description: 'This is an updated work item', - assignedTo: 'user@example.com', - areaPath: 'testproject\\Team A', - iterationPath: 'testproject\\Sprint 1', - priority: 1, - state: 'Active', - additionalFields: { - 'Custom.Field': 'Custom Value' - } - } + updateWorkItem.mockImplementationOnce( + async (_: any, workItemId: number, fields: any) => { + // Call the mocked API + const document: JsonPatchDocument = [ + { + op: 'add', + path: '/fields/System.Title', + value: fields.title, + }, + { + op: 'add', + path: '/fields/System.Description', + value: fields.description, + }, + { + op: 'add', + path: '/fields/System.AssignedTo', + value: fields.assignedTo, + }, + { + op: 'add', + path: '/fields/System.AreaPath', + value: fields.areaPath, + }, + { + op: 'add', + path: '/fields/System.IterationPath', + value: fields.iterationPath, + }, + { + op: 'add', + path: '/fields/Microsoft.VSTS.Common.Priority', + value: fields.priority, + }, + { + op: 'add', + path: '/fields/System.State', + value: fields.state, + }, + { + op: 'add', + path: '/fields/Custom.Field', + value: fields.additionalFields['Custom.Field'], + }, + ]; + + return await mockWorkItemTrackingApi.updateWorkItem( + document, + workItemId, + ); + }, ); - + + // Call updateWorkItem with all fields + const result = await updateWorkItem(mockConnection, 123, { + title: 'Updated Work Item', + description: 'This is an updated work item', + assignedTo: 'user@example.com', + areaPath: 'testproject\\Team A', + iterationPath: 'testproject\\Sprint 1', + priority: 1, + state: 'Active', + additionalFields: { + 'Custom.Field': 'Custom Value', + }, + }); + // Verify the result expect(result).toEqual(mockWorkItem); - + // Verify the API was called correctly expect(mockWorkItemTrackingApi.updateWorkItem).toHaveBeenCalledTimes(1); - + // Verify the document structure - const document: JsonPatchDocument = mockWorkItemTrackingApi.updateWorkItem.mock.calls[0][0]; + const document: JsonPatchDocument = + mockWorkItemTrackingApi.updateWorkItem.mock.calls[0][0]; expect(document).toBeInstanceOf(Array); - + // Verify all operations - const titleOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Title'); + const titleOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Title', + ); expect(titleOperation?.value).toBe('Updated Work Item'); - - const descriptionOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Description'); + + const descriptionOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.Description', + ); expect(descriptionOperation?.value).toBe('This is an updated work item'); - - const assignedToOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.AssignedTo'); + + const assignedToOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.AssignedTo', + ); expect(assignedToOperation?.value).toBe('user@example.com'); - - const areaPathOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.AreaPath'); + + const areaPathOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.AreaPath', + ); expect(areaPathOperation?.value).toBe('testproject\\Team A'); - - const iterationPathOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.IterationPath'); + + const iterationPathOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.IterationPath', + ); expect(iterationPathOperation?.value).toBe('testproject\\Sprint 1'); - - const priorityOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/Microsoft.VSTS.Common.Priority'); + + const priorityOperation = document.find( + (op: JsonPatchOperation) => + op.path === '/fields/Microsoft.VSTS.Common.Priority', + ); expect(priorityOperation?.value).toBe(1); - - const stateOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.State'); + + const stateOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/System.State', + ); expect(stateOperation?.value).toBe('Active'); - - const customFieldOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/Custom.Field'); + + const customFieldOperation = document.find( + (op: JsonPatchOperation) => op.path === '/fields/Custom.Field', + ); expect(customFieldOperation?.value).toBe('Custom Value'); }); - + it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { // Setup mock to throw an error - mockWorkItemTrackingApi.updateWorkItem.mockRejectedValueOnce(new Error('Work item not found')); - + mockWorkItemTrackingApi.updateWorkItem.mockRejectedValueOnce( + new Error('Work item not found'), + ); + // Setup the mock implementation for this test updateWorkItem.mockImplementationOnce(async () => { throw new AzureDevOpsResourceNotFoundError('Work item not found'); }); - + // Call updateWorkItem and expect it to throw - await expect(updateWorkItem( - mockConnection, - 999, - { + await expect( + updateWorkItem(mockConnection, 999, { title: 'Updated Work Item', - } - )).rejects.toThrow(AzureDevOpsResourceNotFoundError); - + }), + ).rejects.toThrow(AzureDevOpsResourceNotFoundError); + // Verify the API was not called expect(mockWorkItemTrackingApi.updateWorkItem).not.toHaveBeenCalled(); }); - + it('should throw an error when API call fails', async () => { // Setup mock to throw an error - mockWorkItemTrackingApi.updateWorkItem.mockRejectedValueOnce(new Error('API call failed')); - + mockWorkItemTrackingApi.updateWorkItem.mockRejectedValueOnce( + new Error('API call failed'), + ); + // Setup the mock implementation for this test updateWorkItem.mockImplementationOnce(async () => { throw new Error('Failed to update work item'); }); - + // Call updateWorkItem and expect it to throw - await expect(updateWorkItem( - mockConnection, - 123, - { + await expect( + updateWorkItem(mockConnection, 123, { title: 'Updated Work Item', - } - )).rejects.toThrow('Failed to update work item'); - + }), + ).rejects.toThrow('Failed to update work item'); + // Verify the API was not called expect(mockWorkItemTrackingApi.updateWorkItem).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 422a079..0000000 --- a/src/index.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import * as dotenv from 'dotenv'; -import { createAzureDevOpsServer, testConnection } from './server'; -import { AzureDevOpsConfig } from './types/config'; -import { AuthenticationMethod } from './auth'; - -// Create a safe console logging function that won't interfere with MCP protocol -function safeLog(message: string) { - process.stderr.write(`${message}\n`); -} - -// Load environment variables -dotenv.config(); - -// Log version info -safeLog('Azure DevOps MCP Server - Starting up'); -safeLog(`Azure DevOps Node API Version: ${require('azure-devops-node-api/package.json').version}`); -safeLog(`MCP SDK Version: ${require('@modelcontextprotocol/sdk/package.json').version}`); - -// Determine the authentication method from environment variables -let authMethod = AuthenticationMethod.AzureIdentity; // Default to Azure Identity -safeLog('Using Azure Identity authentication by default'); -if (process.env.AZURE_DEVOPS_AUTH_METHOD) { - const method = process.env.AZURE_DEVOPS_AUTH_METHOD.toLowerCase(); - if (method === 'azure-identity') { - authMethod = AuthenticationMethod.AzureIdentity; - safeLog('Using Azure Identity authentication'); - } else if (method === 'azure-cli') { - authMethod = AuthenticationMethod.AzureCli; - safeLog('Using Azure CLI authentication'); - } else if (method === 'pat') { - authMethod = AuthenticationMethod.PersonalAccessToken; - safeLog('Using Personal Access Token authentication'); - } else { - safeLog(`Unknown authentication method: ${method}, falling back to Azure Identity`); - } -} - -// Create the server configuration from environment variables -const config: AzureDevOpsConfig = { - organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '', - authMethod: authMethod, - personalAccessToken: process.env.AZURE_DEVOPS_PAT, - defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, - apiVersion: process.env.AZURE_DEVOPS_API_VERSION -}; - -// Validate the required configuration -if (!config.organizationUrl) { - safeLog('Error: AZURE_DEVOPS_ORG_URL environment variable is required'); - process.exit(1); -} - -// Validate PAT if using PAT authentication -if (config.authMethod === AuthenticationMethod.PersonalAccessToken && !config.personalAccessToken) { - safeLog('Error: AZURE_DEVOPS_PAT environment variable is required when using PAT authentication'); - process.exit(1); -} - -// Create the server -export const server = createAzureDevOpsServer(config); - -// Run the server -export async function runServer() { - // Test the connection to Azure DevOps - const connectionSuccessful = await testConnection(config); - - if (!connectionSuccessful) { - safeLog('Error: Failed to connect to Azure DevOps API'); - process.exit(1); - } - - safeLog('Successfully connected to Azure DevOps API'); - safeLog(`Organization URL: ${config.organizationUrl}`); - safeLog(`Authentication Method: ${config.authMethod}`); - - if (config.defaultProject) { - safeLog(`Default Project: ${config.defaultProject}`); - } - - // Connect the server to the stdio transport - const transport = new StdioServerTransport(); - await server.connect(transport); - - safeLog('Azure DevOps MCP Server running on stdio'); -} - -// Start the server -runServer().catch(error => { - safeLog(`Fatal error in main(): ${error}`); - process.exit(1); -}); diff --git a/src/operations/organizations/index.ts b/src/operations/organizations/index.ts deleted file mode 100644 index f729a67..0000000 --- a/src/operations/organizations/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { listOrganizations, Organization, ListOrganizationsSchema } from '../organizations'; - -export { - listOrganizations, - Organization, - ListOrganizationsSchema -}; \ No newline at end of file diff --git a/src/operations/projects.ts b/src/operations/projects.ts deleted file mode 100644 index 81f5064..0000000 --- a/src/operations/projects.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { z } from 'zod'; -import { WebApi } from 'azure-devops-node-api'; -import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; -import { AzureDevOpsResourceNotFoundError } from '../common/errors'; - -/** - * Schema for getting a project - */ -export const GetProjectSchema = z.object({ - projectId: z.string().describe("The ID or name of the project") -}); - -/** - * Schema for listing projects - */ -export const ListProjectsSchema = z.object({ - stateFilter: z.number().optional().describe("Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new)"), - top: z.number().optional().describe("Maximum number of projects to return"), - skip: z.number().optional().describe("Number of projects to skip"), - continuationToken: z.number().optional().describe("Gets the projects after the continuation token provided") -}); - -/** - * Get a project by ID or name - * - * @param connection The Azure DevOps WebApi connection - * @param projectId The ID or name of the project - * @returns The project details - * @throws {AzureDevOpsResourceNotFoundError} If the project is not found - */ -export async function getProject(connection: WebApi, projectId: string): Promise { - try { - const coreApi = await connection.getCoreApi(); - const project = await coreApi.getProject(projectId); - - if (!project) { - throw new AzureDevOpsResourceNotFoundError(`Project '${projectId}' not found`); - } - - return project; - } catch (error) { - if (error instanceof AzureDevOpsResourceNotFoundError) { - throw error; - } - throw new Error(`Failed to get project: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * List all projects in the organization - * - * @param connection The Azure DevOps WebApi connection - * @param options Optional parameters for listing projects - * @returns Array of projects - */ -export async function listProjects( - connection: WebApi, - options: z.infer = {} -): Promise { - try { - const coreApi = await connection.getCoreApi(); - const projects = await coreApi.getProjects( - options.stateFilter, - options.top, - options.skip, - options.continuationToken - ); - - return projects; - } catch (error) { - throw new Error(`Failed to list projects: ${error instanceof Error ? error.message : String(error)}`); - } -} \ No newline at end of file diff --git a/src/operations/repositories.ts b/src/operations/repositories.ts deleted file mode 100644 index 9a71d48..0000000 --- a/src/operations/repositories.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { z } from 'zod'; -import { WebApi } from 'azure-devops-node-api'; -import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { AzureDevOpsResourceNotFoundError } from '../common/errors'; - -/** - * Schema for getting a repository - */ -export const GetRepositorySchema = z.object({ - projectId: z.string().describe("The ID or name of the project"), - repositoryId: z.string().describe("The ID or name of the repository") -}); - -/** - * Schema for listing repositories - */ -export const ListRepositoriesSchema = z.object({ - projectId: z.string().describe("The ID or name of the project"), - includeLinks: z.boolean().optional().describe("Whether to include reference links") -}); - -/** - * Get a repository by ID or name - * - * @param connection The Azure DevOps WebApi connection - * @param projectId The ID or name of the project - * @param repositoryId The ID or name of the repository - * @returns The repository details - * @throws {AzureDevOpsResourceNotFoundError} If the repository is not found - */ -export async function getRepository( - connection: WebApi, - projectId: string, - repositoryId: string -): Promise { - try { - const gitApi = await connection.getGitApi(); - const repository = await gitApi.getRepository(repositoryId, projectId); - - if (!repository) { - throw new AzureDevOpsResourceNotFoundError(`Repository '${repositoryId}' not found in project '${projectId}'`); - } - - return repository; - } catch (error) { - if (error instanceof AzureDevOpsResourceNotFoundError) { - throw error; - } - throw new Error(`Failed to get repository: ${error instanceof Error ? error.message : String(error)}`); - } -} - -/** - * List repositories in a project - * - * @param connection The Azure DevOps WebApi connection - * @param options Parameters for listing repositories - * @returns Array of repositories - */ -export async function listRepositories( - connection: WebApi, - options: z.infer -): Promise { - try { - const gitApi = await connection.getGitApi(); - const repositories = await gitApi.getRepositories(options.projectId, options.includeLinks); - - return repositories; - } catch (error) { - throw new Error(`Failed to list repositories: ${error instanceof Error ? error.message : String(error)}`); - } -} \ No newline at end of file diff --git a/src/operations/workitems.ts b/src/operations/workitems.ts deleted file mode 100644 index 9a1ecc8..0000000 --- a/src/operations/workitems.ts +++ /dev/null @@ -1,459 +0,0 @@ -import { z } from 'zod'; -import { WebApi } from 'azure-devops-node-api'; -import { - WorkItem, - WorkItemExpand, - WorkItemReference, -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { TeamContext } from 'azure-devops-node-api/interfaces/CoreInterfaces'; -import { AzureDevOpsResourceNotFoundError, AzureDevOpsError } from '../common/errors'; - -/** - * Schema for getting a work item - */ -export const GetWorkItemSchema = z.object({ - workItemId: z.number().describe('The ID of the work item'), -}); - -/** - * Schema for listing work items - */ -export const ListWorkItemsSchema = z.object({ - projectId: z.string().describe('The ID or name of the project'), - teamId: z.string().optional().describe('The ID of the team'), - queryId: z.string().optional().describe('ID of a saved work item query'), - wiql: z.string().optional().describe('Work Item Query Language (WIQL) query'), - top: z.number().optional().describe('Maximum number of work items to return'), - skip: z.number().optional().describe('Number of work items to skip'), -}); - -/** - * Schema for creating a work item - */ -export const CreateWorkItemSchema = z.object({ - projectId: z.string().describe('The ID or name of the project'), - workItemType: z.string().describe('The type of work item to create (e.g., "Task", "Bug", "User Story")'), - title: z.string().describe('The title of the work item'), - description: z.string().optional().describe('The description of the work item'), - assignedTo: z.string().optional().describe('The email or name of the user to assign the work item to'), - areaPath: z.string().optional().describe('The area path for the work item'), - iterationPath: z.string().optional().describe('The iteration path for the work item'), - priority: z.number().optional().describe('The priority of the work item'), - additionalFields: z.record(z.string(), z.any()).optional().describe('Additional fields to set on the work item'), -}); - -/** - * Schema for updating a work item - */ -export const UpdateWorkItemSchema = z.object({ - workItemId: z.number().describe('The ID of the work item to update'), - title: z.string().optional().describe('The updated title of the work item'), - description: z.string().optional().describe('The updated description of the work item'), - assignedTo: z.string().optional().describe('The email or name of the user to assign the work item to'), - areaPath: z.string().optional().describe('The updated area path for the work item'), - iterationPath: z.string().optional().describe('The updated iteration path for the work item'), - priority: z.number().optional().describe('The updated priority of the work item'), - state: z.string().optional().describe('The updated state of the work item'), - additionalFields: z.record(z.string(), z.any()).optional().describe('Additional fields to update on the work item'), -}); - -/** - * Options for listing work items - */ -export interface ListWorkItemsOptions { - projectId: string; - teamId?: string; - queryId?: string; - wiql?: string; - top?: number; - skip?: number; -} - -/** - * Options for creating a work item - */ -export interface CreateWorkItemOptions { - title: string; - description?: string; - assignedTo?: string; - areaPath?: string; - iterationPath?: string; - priority?: number; - additionalFields?: Record; -} - -/** - * Options for updating a work item - */ -export interface UpdateWorkItemOptions { - title?: string; - description?: string; - assignedTo?: string; - areaPath?: string; - iterationPath?: string; - priority?: number; - state?: string; - additionalFields?: Record; -} - -/** - * Constructs the default WIQL query for listing work items - * @param projectId The project ID - * @param teamId Optional team ID - * @returns The default WIQL query - */ -function constructDefaultWiql(projectId: string, teamId?: string): string { - let query = `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = '${projectId}'`; - if (teamId) { - query += ` AND [System.TeamId] = '${teamId}'`; - } - query += ' ORDER BY [System.Id]'; - return query; -} - -/** - * Get a work item by ID - * - * @param connection The Azure DevOps WebApi connection - * @param workItemId The ID of the work item - * @param expand Optional expansion options - * @returns The work item details - * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found - */ -export async function getWorkItem( - connection: WebApi, - workItemId: number, - expand?: WorkItemExpand, -): Promise { - try { - const witApi = await connection.getWorkItemTrackingApi(); - const fields = [ - 'System.Id', - 'System.Title', - 'System.State', - 'System.AssignedTo', - ]; - const workItem = await witApi.getWorkItem(workItemId, fields, undefined, expand); - - if (!workItem) { - throw new AzureDevOpsResourceNotFoundError( - `Work item '${workItemId}' not found`, - ); - } - - return workItem; - } catch (error) { - if (error instanceof AzureDevOpsError) { - throw error; - } - throw new Error( - `Failed to get work item: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * List work items in a project - * - * @param connection The Azure DevOps WebApi connection - * @param options Options for listing work items - * @returns Array of work items - */ -export async function listWorkItems( - connection: WebApi, - options: ListWorkItemsOptions, -): Promise { - try { - const witApi = await connection.getWorkItemTrackingApi(); - const { projectId, teamId, queryId, wiql } = options; - - let workItemRefs: WorkItemReference[] = []; - - if (queryId) { - const teamContext: TeamContext = { - project: projectId, - team: teamId - }; - const queryResult = await witApi.queryById(queryId, teamContext); - workItemRefs = queryResult.workItems || []; - } else { - const query = wiql || constructDefaultWiql(projectId, teamId); - const teamContext: TeamContext = { - project: projectId, - team: teamId - }; - const queryResult = await witApi.queryByWiql( - { query }, - teamContext - ); - workItemRefs = queryResult.workItems || []; - } - - // Apply pagination in memory - const { top, skip } = options; - if (skip !== undefined) { - workItemRefs = workItemRefs.slice(skip); - } - if (top !== undefined) { - workItemRefs = workItemRefs.slice(0, top); - } - - const workItemIds = workItemRefs - .map((ref) => ref.id) - .filter((id): id is number => id !== undefined); - - if (workItemIds.length === 0) { - return []; - } - - const fields = [ - 'System.Id', - 'System.Title', - 'System.State', - 'System.AssignedTo', - ]; - const workItems = await witApi.getWorkItems( - workItemIds, - fields, - undefined, - WorkItemExpand.All, - ); - - if (!workItems) { - return []; - } - - return workItems.filter((wi): wi is WorkItem => wi !== undefined); - } catch (error) { - if (error instanceof AzureDevOpsError) { - throw error; - } - throw new Error( - `Failed to list work items: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * Create a work item - * - * @param connection The Azure DevOps WebApi connection - * @param projectId The ID or name of the project - * @param workItemType The type of work item to create (e.g., "Task", "Bug", "User Story") - * @param options Options for creating the work item - * @returns The created work item - */ -export async function createWorkItem( - connection: WebApi, - projectId: string, - workItemType: string, - options: CreateWorkItemOptions -): Promise { - try { - if (!options.title) { - throw new Error('Title is required'); - } - - const witApi = await connection.getWorkItemTrackingApi(); - - // Create the JSON patch document - const document = []; - - // Add required fields - document.push({ - op: 'add', - path: '/fields/System.Title', - value: options.title - }); - - // Add optional fields if provided - if (options.description) { - document.push({ - op: 'add', - path: '/fields/System.Description', - value: options.description - }); - } - - if (options.assignedTo) { - document.push({ - op: 'add', - path: '/fields/System.AssignedTo', - value: options.assignedTo - }); - } - - if (options.areaPath) { - document.push({ - op: 'add', - path: '/fields/System.AreaPath', - value: options.areaPath - }); - } - - if (options.iterationPath) { - document.push({ - op: 'add', - path: '/fields/System.IterationPath', - value: options.iterationPath - }); - } - - if (options.priority !== undefined) { - document.push({ - op: 'add', - path: '/fields/Microsoft.VSTS.Common.Priority', - value: options.priority - }); - } - - // Add any additional fields - if (options.additionalFields) { - for (const [key, value] of Object.entries(options.additionalFields)) { - document.push({ - op: 'add', - path: `/fields/${key}`, - value: value - }); - } - } - - // Create the work item - const workItem = await witApi.createWorkItem(document, {}, projectId, workItemType); - - if (!workItem) { - throw new Error('Failed to create work item'); - } - - return workItem; - } catch (error) { - if (error instanceof AzureDevOpsError) { - throw error; - } - throw new Error( - `Failed to create work item: ${error instanceof Error ? error.message : String(error)}`, - ); - } -} - -/** - * Update a work item - * - * @param connection The Azure DevOps WebApi connection - * @param workItemId The ID of the work item to update - * @param options Options for updating the work item - * @returns The updated work item - * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found - */ -export async function updateWorkItem( - connection: WebApi, - workItemId: number, - options: UpdateWorkItemOptions -): Promise { - try { - const witApi = await connection.getWorkItemTrackingApi(); - - // Create the JSON patch document - const document = []; - - // Add optional fields if provided - if (options.title) { - document.push({ - op: 'add', - path: '/fields/System.Title', - value: options.title - }); - } - - if (options.description) { - document.push({ - op: 'add', - path: '/fields/System.Description', - value: options.description - }); - } - - if (options.assignedTo) { - document.push({ - op: 'add', - path: '/fields/System.AssignedTo', - value: options.assignedTo - }); - } - - if (options.areaPath) { - document.push({ - op: 'add', - path: '/fields/System.AreaPath', - value: options.areaPath - }); - } - - if (options.iterationPath) { - document.push({ - op: 'add', - path: '/fields/System.IterationPath', - value: options.iterationPath - }); - } - - if (options.priority) { - document.push({ - op: 'add', - path: '/fields/Microsoft.VSTS.Common.Priority', - value: options.priority - }); - } - - if (options.state) { - document.push({ - op: 'add', - path: '/fields/System.State', - value: options.state - }); - } - - // Add any additional fields - if (options.additionalFields) { - for (const [key, value] of Object.entries(options.additionalFields)) { - document.push({ - op: 'add', - path: `/fields/${key}`, - value: value - }); - } - } - - // If no fields to update, throw an error - if (document.length === 0) { - throw new Error('At least one field must be provided for update'); - } - - // Update the work item - const updatedWorkItem = await witApi.updateWorkItem( - {}, // customHeaders - document, - workItemId, - undefined, // project - false, // validateOnly - false, // bypassRules - false, // suppressNotifications - WorkItemExpand.All // expand - ); - - if (!updatedWorkItem) { - throw new AzureDevOpsResourceNotFoundError( - `Work item '${workItemId}' not found` - ); - } - - return updatedWorkItem; - } catch (error) { - if (error instanceof AzureDevOpsError) { - throw error; - } - throw new Error( - `Failed to update work item: ${error instanceof Error ? error.message : String(error)}` - ); - } -} diff --git a/tests/unit/server-coverage.test.ts b/src/server.test.ts similarity index 59% rename from tests/unit/server-coverage.test.ts rename to src/server.test.ts index afbc355..b576d82 100644 --- a/tests/unit/server-coverage.test.ts +++ b/src/server.test.ts @@ -1,28 +1,29 @@ import { z } from 'zod'; -import { - AzureDevOpsError, - AzureDevOpsAuthenticationError, - AzureDevOpsValidationError, - AzureDevOpsResourceNotFoundError -} from '../../src/common/errors'; +import { + AzureDevOpsError, + AzureDevOpsAuthenticationError, + AzureDevOpsValidationError, + AzureDevOpsResourceNotFoundError, +} from './shared/errors/azure-devops-errors'; +import { AzureDevOpsConfig } from './shared/types'; // Define schema objects const ListProjectsSchema = z.object({ top: z.number().optional(), skip: z.number().optional(), includeCapabilities: z.boolean().optional(), - includeHistory: z.boolean().optional() + includeHistory: z.boolean().optional(), }); const GetProjectSchema = z.object({ projectId: z.string(), includeCapabilities: z.boolean().optional(), - includeHistory: z.boolean().optional() + includeHistory: z.boolean().optional(), }); const GetWorkItemSchema = z.object({ workItemId: z.number(), - expand: z.string().optional() + expand: z.string().optional(), }); const ListWorkItemsSchema = z.object({ @@ -31,18 +32,18 @@ const ListWorkItemsSchema = z.object({ wiql: z.string().optional(), teamId: z.string().optional(), top: z.number().optional(), - skip: z.number().optional() + skip: z.number().optional(), }); const GetRepositorySchema = z.object({ projectId: z.string(), repositoryId: z.string(), - includeLinks: z.boolean().optional() + includeLinks: z.boolean().optional(), }); const ListRepositoriesSchema = z.object({ projectId: z.string(), - includeLinks: z.boolean().optional() + includeLinks: z.boolean().optional(), }); const CreateWorkItemSchema = z.object({ @@ -54,90 +55,113 @@ const CreateWorkItemSchema = z.object({ areaPath: z.string().optional(), iterationPath: z.string().optional(), priority: z.number().optional(), - additionalFields: z.record(z.string(), z.any()).optional() + additionalFields: z.record(z.string(), z.any()).optional(), }); -// Import the mocked modules -import * as projectsMock from '../../src/operations/projects'; -import * as workitemsMock from '../../src/operations/workitems'; +// Define mock server class +class MockServerClass { + setRequestHandler = jest.fn(); + registerTool = jest.fn(); + capabilities = { + tools: {} as Record, + }; +} // Define mock functions before imports -const mockWebApiConstructor = jest.fn().mockImplementation((_url: string, _requestHandler: any) => { - return { - getLocationsApi: jest.fn().mockResolvedValue({ - getResourceAreas: jest.fn().mockResolvedValue([]) - }), - getCoreApi: jest.fn().mockResolvedValue({ - getProjects: jest.fn().mockResolvedValue([]) - }), - getGitApi: jest.fn(), - getWorkItemTrackingApi: jest.fn() - }; -}); +const mockWebApiConstructor = jest + .fn() + .mockImplementation((_url: string, _requestHandler: any) => { + return { + getLocationsApi: jest.fn().mockResolvedValue({ + getResourceAreas: jest.fn().mockResolvedValue([]), + }), + getCoreApi: jest.fn().mockResolvedValue({ + getProjects: jest.fn().mockResolvedValue([]), + }), + getGitApi: jest.fn(), + getWorkItemTrackingApi: jest.fn(), + }; + }); const mockGetPersonalAccessTokenHandler = jest.fn(); // Mock modules before imports jest.mock('azure-devops-node-api', () => ({ WebApi: mockWebApiConstructor, - getPersonalAccessTokenHandler: mockGetPersonalAccessTokenHandler + getPersonalAccessTokenHandler: mockGetPersonalAccessTokenHandler, +})); + +// Mock the feature modules +jest.mock('./features/projects/list-projects/feature', () => ({ + listProjects: jest.fn(), +})); + +jest.mock('./features/projects/get-project/feature', () => ({ + getProject: jest.fn(), +})); + +jest.mock('./features/work-items/get-work-item/feature', () => ({ + getWorkItem: jest.fn(), +})); + +jest.mock('./features/work-items/list-work-items/feature', () => ({ + listWorkItems: jest.fn(), +})); + +jest.mock('./features/work-items/create-work-item/feature', () => ({ + createWorkItem: jest.fn(), +})); + +jest.mock('./features/repositories/get-repository/feature', () => ({ + getRepository: jest.fn(), })); -// Mock the operations modules -jest.mock('../../src/operations/projects', () => ({ +jest.mock('./features/repositories/list-repositories/feature', () => ({ + listRepositories: jest.fn(), +})); + +// Mock the schema modules +jest.mock('./features/projects/schemas', () => ({ ListProjectsSchema, GetProjectSchema, - listProjects: jest.fn(), - getProject: jest.fn() })); -jest.mock('../../src/operations/workitems', () => ({ +jest.mock('./features/work-items/schemas', () => ({ GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, - getWorkItem: jest.fn(), - listWorkItems: jest.fn(), - createWorkItem: jest.fn() })); -jest.mock('../../src/operations/repositories', () => ({ +jest.mock('./features/repositories/schemas', () => ({ GetRepositorySchema, ListRepositoriesSchema, - getRepository: jest.fn(), - listRepositories: jest.fn() })); -// Define mock server class -class MockServerClass { - setRequestHandler = jest.fn(); - registerTool = jest.fn(); - capabilities = { - tools: {} as Record - }; -} - -// Mock the modules +// Mock the MCP SDK modules jest.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ - Server: jest.fn().mockImplementation(() => new MockServerClass()) + Server: jest.fn().mockImplementation(() => new MockServerClass()), })); jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ ListToolsRequestSchema: 'ListToolsRequestSchema', - CallToolRequestSchema: 'CallToolRequestSchema' + CallToolRequestSchema: 'CallToolRequestSchema', })); import { WebApi } from 'azure-devops-node-api'; -import { AzureDevOpsConfig } from '../../src/types/config'; import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces'; -import { createAzureDevOpsServer } from '../../src/server'; -import { getProject, listProjects } from '../../src/operations/projects'; -import { getWorkItem, listWorkItems, createWorkItem } from '../../src/operations/workitems'; -import { getRepository, listRepositories } from '../../src/operations/repositories'; - -describe('Server Coverage Tests', () => { +import { createAzureDevOpsServer } from './server'; +import { getProject } from './features/projects/get-project/feature'; +import { listProjects } from './features/projects/list-projects/feature'; +import { getWorkItem } from './features/work-items/get-work-item/feature'; +import { listWorkItems } from './features/work-items/list-work-items/feature'; +import { createWorkItem } from './features/work-items/create-work-item/feature'; +import { getRepository } from './features/repositories/get-repository/feature'; +import { listRepositories } from './features/repositories/list-repositories/feature'; + +describe('Server Tests', () => { let mockServer: MockServerClass; let callToolHandler: any; - + const validConfig: AzureDevOpsConfig = { organizationUrl: 'https://dev.azure.com/test', personalAccessToken: 'test-pat', @@ -145,19 +169,21 @@ describe('Server Coverage Tests', () => { beforeEach(() => { jest.clearAllMocks(); - + // Initialize the mock server mockServer = new MockServerClass(); - + // Mock the Server constructor to return our mockServer - (require('@modelcontextprotocol/sdk/server/index.js').Server as jest.Mock).mockReturnValue(mockServer); - + ( + require('@modelcontextprotocol/sdk/server/index.js').Server as jest.Mock + ).mockReturnValue(mockServer); + // Create server instance createAzureDevOpsServer(validConfig); - + // Define the callToolHandler function callToolHandler = mockServer.setRequestHandler.mock.calls.find( - (call: any[]) => call[0] === 'CallToolRequestSchema' + (call: any[]) => call[0] === 'CallToolRequestSchema', )?.[1]; }); @@ -171,8 +197,14 @@ describe('Server Coverage Tests', () => { }); it('should set request handlers', () => { - expect(mockServer.setRequestHandler).toHaveBeenCalledWith('ListToolsRequestSchema', expect.any(Function)); - expect(mockServer.setRequestHandler).toHaveBeenCalledWith('CallToolRequestSchema', expect.any(Function)); + expect(mockServer.setRequestHandler).toHaveBeenCalledWith( + 'ListToolsRequestSchema', + expect.any(Function), + ); + expect(mockServer.setRequestHandler).toHaveBeenCalledWith( + 'CallToolRequestSchema', + expect.any(Function), + ); }); }); @@ -180,12 +212,14 @@ describe('Server Coverage Tests', () => { it('should create a WebApi instance with the correct parameters', () => { const requestHandler: IRequestHandler = { prepareRequest: (options) => { - options.headers = { Authorization: `Basic ${Buffer.from(':test-pat').toString('base64')}` }; + options.headers = { + Authorization: `Basic ${Buffer.from(':test-pat').toString('base64')}`, + }; }, canHandleAuthentication: () => false, handleAuthentication: async () => { throw new Error('Authentication not supported'); - } + }, }; const webApi = new WebApi('https://dev.azure.com/test', requestHandler); expect(webApi).toBeDefined(); @@ -202,31 +236,33 @@ describe('Server Coverage Tests', () => { const response = await callToolHandler({ params: { name: 'unknown_tool', - arguments: {} - } + arguments: {}, + }, }); - + expect(response.content[0].text).toContain('Unknown tool: unknown_tool'); }); it('should handle missing arguments error', async () => { const response = await callToolHandler({ params: { - name: 'list_projects' - } + name: 'list_projects', + }, }); - + expect(response.content[0].text).toContain('Arguments are required'); }); it('should handle list_projects tool call', async () => { - (listProjects as jest.Mock).mockResolvedValueOnce([{ id: 'project1', name: 'Project 1' }]); - + (listProjects as jest.Mock).mockResolvedValueOnce([ + { id: 'project1', name: 'Project 1' }, + ]); + const result = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); // Extract the actual data from the content array @@ -236,13 +272,16 @@ describe('Server Coverage Tests', () => { }); it('should handle get_project tool call', async () => { - (getProject as jest.Mock).mockResolvedValueOnce({ id: 'project1', name: 'Project 1' }); - + (getProject as jest.Mock).mockResolvedValueOnce({ + id: 'project1', + name: 'Project 1', + }); + const result = await callToolHandler({ params: { name: 'get_project', - arguments: { projectId: 'project1' } - } + arguments: { projectId: 'project1' }, + }, }); // Extract the actual data from the content array @@ -252,176 +291,211 @@ describe('Server Coverage Tests', () => { }); it('should handle get_work_item tool call', async () => { - (getWorkItem as jest.Mock).mockResolvedValueOnce({ id: 123, fields: { 'System.Title': 'Test Work Item' } }); - + (getWorkItem as jest.Mock).mockResolvedValueOnce({ + id: 123, + fields: { 'System.Title': 'Test Work Item' }, + }); + const result = await callToolHandler({ params: { name: 'get_work_item', - arguments: { workItemId: 123 } - } + arguments: { workItemId: 123 }, + }, }); // Extract the actual data from the content array const resultData = JSON.parse(result.content[0].text); - expect(resultData).toEqual({ id: 123, fields: { 'System.Title': 'Test Work Item' } }); + expect(resultData).toEqual({ + id: 123, + fields: { 'System.Title': 'Test Work Item' }, + }); expect(getWorkItem).toHaveBeenCalledWith(expect.anything(), 123); }); it('should handle list_work_items tool call', async () => { - (listWorkItems as jest.Mock).mockResolvedValueOnce([{ id: 123, fields: { 'System.Title': 'Test Work Item' } }]); - + (listWorkItems as jest.Mock).mockResolvedValueOnce([ + { id: 123, fields: { 'System.Title': 'Test Work Item' } }, + ]); + const result = await callToolHandler({ params: { name: 'list_work_items', - arguments: { projectId: 'project1', wiql: 'SELECT * FROM WorkItems' } - } + arguments: { projectId: 'project1', wiql: 'SELECT * FROM WorkItems' }, + }, }); // Extract the actual data from the content array const resultData = JSON.parse(result.content[0].text); - expect(resultData).toEqual([{ id: 123, fields: { 'System.Title': 'Test Work Item' } }]); + expect(resultData).toEqual([ + { id: 123, fields: { 'System.Title': 'Test Work Item' } }, + ]); expect(listWorkItems).toHaveBeenCalledWith(expect.anything(), { projectId: 'project1', - wiql: 'SELECT * FROM WorkItems' + wiql: 'SELECT * FROM WorkItems', }); }); it('should handle get_repository tool call', async () => { - (getRepository as jest.Mock).mockResolvedValueOnce({ id: 'repo1', name: 'Repository 1' }); - + (getRepository as jest.Mock).mockResolvedValueOnce({ + id: 'repo1', + name: 'Repository 1', + }); + const result = await callToolHandler({ params: { name: 'get_repository', - arguments: { projectId: 'project1', repositoryId: 'repo1' } - } + arguments: { projectId: 'project1', repositoryId: 'repo1' }, + }, }); // Extract the actual data from the content array const resultData = JSON.parse(result.content[0].text); expect(resultData).toEqual({ id: 'repo1', name: 'Repository 1' }); - expect(getRepository).toHaveBeenCalledWith(expect.anything(), 'project1', 'repo1'); + expect(getRepository).toHaveBeenCalledWith( + expect.anything(), + 'project1', + 'repo1', + ); }); it('should handle list_repositories tool call', async () => { - (listRepositories as jest.Mock).mockResolvedValueOnce([{ id: 'repo1', name: 'Repository 1' }]); - + (listRepositories as jest.Mock).mockResolvedValueOnce([ + { id: 'repo1', name: 'Repository 1' }, + ]); + const result = await callToolHandler({ params: { name: 'list_repositories', - arguments: { projectId: 'project1' } - } + arguments: { projectId: 'project1' }, + }, }); // Extract the actual data from the content array const resultData = JSON.parse(result.content[0].text); expect(resultData).toEqual([{ id: 'repo1', name: 'Repository 1' }]); - expect(listRepositories).toHaveBeenCalledWith(expect.anything(), { projectId: 'project1' }); + expect(listRepositories).toHaveBeenCalledWith(expect.anything(), { + projectId: 'project1', + }); }); it('should handle ZodError and return validation error message', async () => { // Mock a function to throw a ZodError - const zodError = new Error(); + const zodError = new Error('Expected number, received string'); zodError.name = 'ZodError'; - zodError.message = 'Expected number, received string'; - workitemsMock.GetWorkItemSchema.parse = jest.fn().mockImplementation(() => { + + // Mock getWorkItem to simulate throwing a validation error + (getWorkItem as jest.Mock).mockImplementationOnce(() => { throw zodError; }); - + const response = await callToolHandler({ params: { name: 'get_work_item', - arguments: { workItemId: 'string-instead-of-number' } - } + arguments: { workItemId: 'string-instead-of-number' }, + }, }); - - expect(response.content[0].text).toContain('Expected number, received string'); + + expect(response.content[0].text).toContain( + 'Expected number, received string', + ); }); it('should handle AzureDevOpsError and format the error message', async () => { - // Make projects.listProjects throw an AzureDevOpsError - (projectsMock.listProjects as jest.Mock).mockImplementationOnce(() => { + // Make listProjects throw an AzureDevOpsError + (listProjects as jest.Mock).mockImplementationOnce(() => { throw new AzureDevOpsError('Test error'); }); - + const response = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); - - expect(response.content[0].text).toContain('Azure DevOps API Error: Test error'); + + expect(response.content[0].text).toContain( + 'Azure DevOps API Error: Test error', + ); }); it('should handle AzureDevOpsValidationError and format the error message', async () => { - // Make projects.listProjects throw an AzureDevOpsValidationError - (projectsMock.listProjects as jest.Mock).mockImplementationOnce(() => { + // Make listProjects throw an AzureDevOpsValidationError + (listProjects as jest.Mock).mockImplementationOnce(() => { throw new AzureDevOpsValidationError('Validation failed'); }); - + const response = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); - - expect(response.content[0].text).toContain('Validation Error: Validation failed'); + + expect(response.content[0].text).toContain( + 'Validation Error: Validation failed', + ); }); it('should handle AzureDevOpsResourceNotFoundError and format the error message', async () => { - // Make projects.listProjects throw an AzureDevOpsResourceNotFoundError - (projectsMock.listProjects as jest.Mock).mockImplementationOnce(() => { + // Make listProjects throw an AzureDevOpsResourceNotFoundError + (listProjects as jest.Mock).mockImplementationOnce(() => { throw new AzureDevOpsResourceNotFoundError('Resource not found'); }); - + const response = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); - - expect(response.content[0].text).toContain('Not Found: Resource not found'); + + expect(response.content[0].text).toContain( + 'Not Found: Resource not found', + ); }); it('should handle AzureDevOpsAuthenticationError and format the error message', async () => { - // Make projects.listProjects throw an AzureDevOpsAuthenticationError - (projectsMock.listProjects as jest.Mock).mockImplementationOnce(() => { + // Make listProjects throw an AzureDevOpsAuthenticationError + (listProjects as jest.Mock).mockImplementationOnce(() => { throw new AzureDevOpsAuthenticationError('Authentication failed'); }); - + const response = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); - - expect(response.content[0].text).toContain('Authentication Failed: Authentication failed'); + + expect(response.content[0].text).toContain( + 'Authentication Failed: Authentication failed', + ); }); it('should handle generic errors and format the error message', async () => { - // Make projects.listProjects throw a generic Error - (projectsMock.listProjects as jest.Mock).mockImplementationOnce(() => { + // Make listProjects throw a generic Error + (listProjects as jest.Mock).mockImplementationOnce(() => { throw new Error('Generic error'); }); - + const response = await callToolHandler({ params: { name: 'list_projects', - arguments: { top: 10 } - } + arguments: { top: 10 }, + }, }); - + expect(response.content[0].text).toContain('Error: Generic error'); }); it('should handle create_work_item tool call', async () => { // Mock the workitems.createWorkItem function - const mockWorkItem = { id: 123, fields: { 'System.Title': 'Test Work Item' } }; + const mockWorkItem = { + id: 123, + fields: { 'System.Title': 'Test Work Item' }, + }; (createWorkItem as jest.Mock).mockResolvedValueOnce(mockWorkItem); - + // Call the handler with create_work_item parameters const request = { params: { @@ -430,18 +504,20 @@ describe('Server Coverage Tests', () => { projectId: 'testproject', workItemType: 'Task', title: 'Test Work Item', - description: 'This is a test work item' - } - } + description: 'This is a test work item', + }, + }, }; - + const response = await callToolHandler(request as any); - + // Verify the response expect(response).toEqual({ - content: [{ type: 'text', text: JSON.stringify(mockWorkItem, null, 2) }] + content: [ + { type: 'text', text: JSON.stringify(mockWorkItem, null, 2) }, + ], }); - + // Verify the createWorkItem function was called with the correct parameters expect(createWorkItem).toHaveBeenCalledWith( expect.anything(), @@ -449,9 +525,9 @@ describe('Server Coverage Tests', () => { 'Task', { title: 'Test Work Item', - description: 'This is a test work item' - } + description: 'This is a test work item', + }, ); }); }); -}); \ No newline at end of file +}); diff --git a/src/server.ts b/src/server.ts index 013d182..f60b7d8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,26 +1,51 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from '@modelcontextprotocol/sdk/types.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { WebApi } from 'azure-devops-node-api'; -import { VERSION } from "./config/version"; -import { AzureDevOpsConfig } from "./types/config"; -import { - AzureDevOpsAuthenticationError, +import { VERSION } from './shared/config'; +import { AzureDevOpsConfig } from './shared/types'; +import { + AzureDevOpsAuthenticationError, AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, - isAzureDevOpsError -} from "./common/errors"; -import { AuthenticationMethod, AzureDevOpsClient } from './auth'; + isAzureDevOpsError, +} from './shared/errors'; +import { AuthenticationMethod, AzureDevOpsClient } from './shared/auth'; + +// Import our new feature modules +import { + ListWorkItemsSchema, + GetWorkItemSchema, + CreateWorkItemSchema, + UpdateWorkItemSchema, + listWorkItems, + getWorkItem, + createWorkItem, + updateWorkItem, +} from './features/work-items'; + +import { + GetProjectSchema, + ListProjectsSchema, + getProject, + listProjects, +} from './features/projects'; + +import { + GetRepositorySchema, + ListRepositoriesSchema, + getRepository, + listRepositories, +} from './features/repositories'; -// Import our operation modules -import * as projects from './operations/projects'; -import * as workitems from './operations/workitems'; -import * as repositories from './operations/repositories'; -import * as organizations from './operations/organizations'; +import { + ListOrganizationsSchema, + listOrganizations, +} from './features/organizations'; // Create a safe console logging function that won't interfere with MCP protocol function safeLog(message: string) { @@ -29,79 +54,80 @@ function safeLog(message: string) { /** * Create an Azure DevOps MCP Server - * + * * @param config The Azure DevOps configuration * @returns A configured MCP server instance */ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { // Validate the configuration validateConfig(config); - + // Initialize the MCP server const server = new Server( { - name: "azure-devops-mcp", + name: 'azure-devops-mcp', version: VERSION, }, { capabilities: { tools: {}, }, - } + }, ); // Register the ListTools request handler - server.setRequestHandler(ListToolsRequestSchema, async () => { + server.setRequestHandler(ListToolsRequestSchema, () => { return { tools: [ // Organization tools { - name: "list_organizations", - description: "List all Azure DevOps organizations accessible to the authenticated user", - inputSchema: zodToJsonSchema(organizations.ListOrganizationsSchema), + name: 'list_organizations', + description: + 'List all Azure DevOps organizations accessible to the current authentication', + inputSchema: zodToJsonSchema(ListOrganizationsSchema), }, // Project tools { - name: "list_projects", - description: "List all projects in the Azure DevOps organization", - inputSchema: zodToJsonSchema(projects.ListProjectsSchema), + name: 'list_projects', + description: 'List all projects in an organization', + inputSchema: zodToJsonSchema(ListProjectsSchema), }, { - name: "get_project", - description: "Get details of a specific project", - inputSchema: zodToJsonSchema(projects.GetProjectSchema), + name: 'get_project', + description: 'Get details of a specific project', + inputSchema: zodToJsonSchema(GetProjectSchema), }, // Work item tools { - name: "get_work_item", - description: "Get details of a specific work item", - inputSchema: zodToJsonSchema(workitems.GetWorkItemSchema), + name: 'get_work_item', + description: 'Get details of a specific work item', + inputSchema: zodToJsonSchema(GetWorkItemSchema), }, { - name: "list_work_items", - description: "List work items in a project", - inputSchema: zodToJsonSchema(workitems.ListWorkItemsSchema), + name: 'list_work_items', + description: 'List work items in a project', + inputSchema: zodToJsonSchema(ListWorkItemsSchema), }, { - name: "create_work_item", - description: "Create a new work item in a project", - inputSchema: zodToJsonSchema(workitems.CreateWorkItemSchema), + name: 'create_work_item', + description: 'Create a new work item', + inputSchema: zodToJsonSchema(CreateWorkItemSchema), }, { - name: "update_work_item", - description: "Update an existing work item", - inputSchema: zodToJsonSchema(workitems.UpdateWorkItemSchema), + name: 'update_work_item', + description: 'Update an existing work item', + inputSchema: zodToJsonSchema(UpdateWorkItemSchema), }, // Repository tools { - name: "get_repository", - description: "Get details of a specific repository", - inputSchema: zodToJsonSchema(repositories.GetRepositorySchema), + name: 'get_repository', + description: 'Get details of a specific repository', + inputSchema: zodToJsonSchema(GetRepositorySchema), }, { - name: "list_repositories", - description: "List repositories in a project", - inputSchema: zodToJsonSchema(repositories.ListRepositoriesSchema), + name: 'list_repositories', + description: 'List repositories in a project', + inputSchema: zodToJsonSchema(ListRepositoriesSchema), }, ], }; @@ -111,7 +137,7 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments) { - throw new AzureDevOpsValidationError("Arguments are required"); + throw new AzureDevOpsValidationError('Arguments are required'); } // Get a connection to Azure DevOps @@ -121,50 +147,47 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { // Organization tools case 'list_organizations': { // Parse arguments but they're not used since this tool doesn't have parameters - organizations.ListOrganizationsSchema.parse(request.params.arguments); - const result = await organizations.listOrganizations(config); + ListOrganizationsSchema.parse(request.params.arguments); + const result = await listOrganizations(config); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - + // Project tools case 'list_projects': { - const args = projects.ListProjectsSchema.parse(request.params.arguments); - const result = await projects.listProjects(connection, args); + const args = ListProjectsSchema.parse(request.params.arguments); + const result = await listProjects(connection, args); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'get_project': { - const args = projects.GetProjectSchema.parse(request.params.arguments); - const result = await projects.getProject(connection, args.projectId); + const args = GetProjectSchema.parse(request.params.arguments); + const result = await getProject(connection, args.projectId); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - + // Work item tools case 'get_work_item': { - const args = workitems.GetWorkItemSchema.parse(request.params.arguments); - const result = await workitems.getWorkItem( - connection, - args.workItemId - ); + const args = GetWorkItemSchema.parse(request.params.arguments); + const result = await getWorkItem(connection, args.workItemId); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'list_work_items': { - const args = workitems.ListWorkItemsSchema.parse(request.params.arguments); - const result = await workitems.listWorkItems(connection, args); + const args = ListWorkItemsSchema.parse(request.params.arguments); + const result = await listWorkItems(connection, args); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'create_work_item': { - const args = workitems.CreateWorkItemSchema.parse(request.params.arguments); - const result = await workitems.createWorkItem( + const args = CreateWorkItemSchema.parse(request.params.arguments); + const result = await createWorkItem( connection, args.projectId, args.workItemType, @@ -175,67 +198,63 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { areaPath: args.areaPath, iterationPath: args.iterationPath, priority: args.priority, - additionalFields: args.additionalFields - } + additionalFields: args.additionalFields, + }, ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'update_work_item': { - const args = workitems.UpdateWorkItemSchema.parse(request.params.arguments); - const result = await workitems.updateWorkItem( - connection, - args.workItemId, - { - title: args.title, - description: args.description, - assignedTo: args.assignedTo, - areaPath: args.areaPath, - iterationPath: args.iterationPath, - priority: args.priority, - state: args.state, - additionalFields: args.additionalFields - } - ); + const args = UpdateWorkItemSchema.parse(request.params.arguments); + const result = await updateWorkItem(connection, args.workItemId, { + title: args.title, + description: args.description, + assignedTo: args.assignedTo, + areaPath: args.areaPath, + iterationPath: args.iterationPath, + priority: args.priority, + state: args.state, + additionalFields: args.additionalFields, + }); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - + // Repository tools case 'get_repository': { - const args = repositories.GetRepositorySchema.parse(request.params.arguments); - const result = await repositories.getRepository( + const args = GetRepositorySchema.parse(request.params.arguments); + const result = await getRepository( connection, args.projectId, - args.repositoryId + args.repositoryId, ); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } case 'list_repositories': { - const args = repositories.ListRepositoriesSchema.parse(request.params.arguments); - const result = await repositories.listRepositories(connection, args); + const args = ListRepositoriesSchema.parse(request.params.arguments); + const result = await listRepositories(connection, args); return { - content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], }; } - + default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { safeLog(`Error handling tool call: ${error}`); - + // Format the error message const errorMessage = isAzureDevOpsError(error) ? formatAzureDevOpsError(error) : `Error: ${error instanceof Error ? error.message : String(error)}`; - + return { - content: [{ type: "text", text: errorMessage }], + content: [{ type: 'text', text: errorMessage }], }; } }); @@ -245,13 +264,13 @@ export function createAzureDevOpsServer(config: AzureDevOpsConfig): Server { /** * Format an Azure DevOps error for display - * + * * @param error The error to format * @returns Formatted error message */ function formatAzureDevOpsError(error: AzureDevOpsError): string { let message = `Azure DevOps API Error: ${error.message}`; - + if (error instanceof AzureDevOpsValidationError) { message = `Validation Error: ${error.message}`; } else if (error instanceof AzureDevOpsResourceNotFoundError) { @@ -265,7 +284,7 @@ function formatAzureDevOpsError(error: AzureDevOpsError): string { /** * Validate the Azure DevOps configuration - * + * * @param config The configuration to validate * @throws {AzureDevOpsValidationError} If the configuration is invalid */ @@ -280,45 +299,56 @@ function validateConfig(config: AzureDevOpsConfig): void { } // Validate PAT if using PAT authentication - if (config.authMethod === AuthenticationMethod.PersonalAccessToken && !config.personalAccessToken) { - throw new AzureDevOpsValidationError('Personal Access Token is required for PAT authentication'); + if ( + config.authMethod === AuthenticationMethod.PersonalAccessToken && + !config.personalAccessToken + ) { + throw new AzureDevOpsValidationError( + 'Personal Access Token is required for PAT authentication', + ); } } /** * Get an authenticated connection to Azure DevOps - * + * * @param config The Azure DevOps configuration * @returns An authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ -export async function getConnection(config: AzureDevOpsConfig): Promise { +export async function getConnection( + config: AzureDevOpsConfig, +): Promise { try { // Create a client with the appropriate authentication method const client = new AzureDevOpsClient({ method: config.authMethod || AuthenticationMethod.PersonalAccessToken, organizationUrl: config.organizationUrl, - personalAccessToken: config.personalAccessToken + personalAccessToken: config.personalAccessToken, }); - + // Test the connection by getting the Core API await client.getCoreApi(); - + // Return the underlying WebApi client return await client.getWebApiClient(); } catch (error) { safeLog(`Connection error details: ${error}`); - throw new AzureDevOpsAuthenticationError(`Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`); + throw new AzureDevOpsAuthenticationError( + `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`, + ); } } /** * Test the connection to Azure DevOps - * + * * @param config The Azure DevOps configuration * @returns True if the connection is successful, false otherwise */ -export async function testConnection(config: AzureDevOpsConfig): Promise { +export async function testConnection( + config: AzureDevOpsConfig, +): Promise { try { safeLog(`Testing connection to ${config.organizationUrl}...`); await getConnection(config); @@ -328,4 +358,4 @@ export async function testConnection(config: AzureDevOpsConfig): Promise; -const mockCreateAuthenticatedClient = auth.createAuthenticatedClient as jest.MockedFunction; +const MockSharedClient = SharedClient as jest.MockedClass; describe('AzureDevOpsClient', () => { const config = { pat: 'validpat', - orgUrl: 'https://dev.azure.com/org' + orgUrl: 'https://dev.azure.com/org', }; let client: AzureDevOpsClient; let mockWebApiInstance: any; + let mockSharedClientInstance: any; let mockGetCoreApi: jest.Mock; let mockGetGitApi: jest.Mock; let mockGetWorkItemTrackingApi: jest.Mock; @@ -27,6 +27,7 @@ describe('AzureDevOpsClient', () => { let mockGetReleaseApi: jest.Mock; let mockGetTaskAgentApi: jest.Mock; let mockGetTaskApi: jest.Mock; + let mockGetWebApiClient: jest.Mock; beforeEach(() => { jest.clearAllMocks(); @@ -40,6 +41,7 @@ describe('AzureDevOpsClient', () => { mockGetReleaseApi = jest.fn().mockResolvedValue({}); mockGetTaskAgentApi = jest.fn().mockResolvedValue({}); mockGetTaskApi = jest.fn().mockResolvedValue({}); + mockGetWebApiClient = jest.fn().mockResolvedValue({}); mockWebApiInstance = { getCoreApi: mockGetCoreApi, @@ -49,53 +51,75 @@ describe('AzureDevOpsClient', () => { getTestApi: mockGetTestApi, getReleaseApi: mockGetReleaseApi, getTaskAgentApi: mockGetTaskAgentApi, - getTaskApi: mockGetTaskApi + getTaskApi: mockGetTaskApi, }; - MockWebApi.mockImplementation(() => mockWebApiInstance); - - // Mock the createAuthenticatedClient function to return our mockWebApiInstance - mockCreateAuthenticatedClient.mockResolvedValue(mockWebApiInstance); + mockSharedClientInstance = { + getWebApiClient: + mockGetWebApiClient.mockResolvedValue(mockWebApiInstance), + getCoreApi: mockGetCoreApi, + getGitApi: mockGetGitApi, + getWorkItemTrackingApi: mockGetWorkItemTrackingApi, + getBuildApi: mockGetBuildApi, + getTestApi: mockGetTestApi, + getReleaseApi: mockGetReleaseApi, + getTaskAgentApi: mockGetTaskAgentApi, + getTaskApi: mockGetTaskApi, + }; + + MockSharedClient.mockImplementation(() => mockSharedClientInstance); client = new AzureDevOpsClient(config); }); describe('getClient', () => { - it('should create WebApi client only once', async () => { + it('should create SharedClient only once', async () => { await client.getCoreApi(); await client.getCoreApi(); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledWith(config); + expect(MockSharedClient).toHaveBeenCalledTimes(1); + expect(MockSharedClient).toHaveBeenCalledWith({ + method: AuthenticationMethod.PersonalAccessToken, + organizationUrl: config.orgUrl, + personalAccessToken: config.pat, + }); }); - + it('should re-use the cached client for multiple API calls', async () => { await client.getCoreApi(); await client.getGitApi(); await client.getWorkItemTrackingApi(); - - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); + + expect(MockSharedClient).toHaveBeenCalledTimes(1); }); it('should handle authentication errors when creating client', async () => { - mockCreateAuthenticatedClient.mockRejectedValueOnce(new AzureDevOpsAuthenticationError('Authentication failed')); - - await expect(client.getCoreApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); + mockGetWebApiClient.mockRejectedValueOnce( + new AzureDevOpsAuthenticationError('Authentication failed'), + ); + + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + expect(MockSharedClient).toHaveBeenCalledTimes(1); }); - + it('should handle generic errors when creating client', async () => { - mockCreateAuthenticatedClient.mockRejectedValueOnce(new Error('Network error')); - - await expect(client.getCoreApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); + mockGetWebApiClient.mockRejectedValueOnce(new Error('Network error')); + + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + expect(MockSharedClient).toHaveBeenCalledTimes(1); }); - + it('should handle non-Error instances when creating client', async () => { - mockCreateAuthenticatedClient.mockRejectedValueOnce('String error'); - - await expect(client.getCoreApi()).rejects.toThrow('Authentication failed: Unknown error'); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); + mockGetWebApiClient.mockRejectedValueOnce('String error'); + + await expect(client.getCoreApi()).rejects.toThrow( + 'Authentication failed: Unknown error', + ); + expect(MockSharedClient).toHaveBeenCalledTimes(1); }); }); @@ -150,111 +174,148 @@ describe('AzureDevOpsClient', () => { it('should throw authentication error if API call fails', async () => { mockGetCoreApi.mockRejectedValue(new Error('API Error')); - await expect(client.getCoreApi()).rejects.toThrow(AzureDevOpsAuthenticationError); + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); }); - + it('should rethrow AzureDevOpsError if API call fails with that error type', async () => { const apiError = new AzureDevOpsError('API Error'); mockGetCoreApi.mockRejectedValue(apiError); await expect(client.getCoreApi()).rejects.toThrow(apiError); }); - + it('should handle error in getGitApi', async () => { mockGetGitApi.mockRejectedValue(new Error('Git API Error')); - await expect(client.getGitApi()).rejects.toThrow(AzureDevOpsAuthenticationError); + await expect(client.getGitApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); await expect(client.getGitApi()).rejects.toThrow('Failed to get Git API'); }); - + it('should handle non-Error instance in getGitApi', async () => { mockGetGitApi.mockRejectedValue('String error'); - await expect(client.getGitApi()).rejects.toThrow('Failed to get Git API: Unknown error'); + await expect(client.getGitApi()).rejects.toThrow( + 'Failed to get Git API: Unknown error', + ); }); - + it('should handle error in getWorkItemTrackingApi', async () => { - mockGetWorkItemTrackingApi.mockRejectedValue(new Error('Work Item API Error')); - await expect(client.getWorkItemTrackingApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getWorkItemTrackingApi()).rejects.toThrow('Failed to get Work Item Tracking API'); + mockGetWorkItemTrackingApi.mockRejectedValue( + new Error('Work Item API Error'), + ); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + 'Failed to get Work Item Tracking API', + ); }); - + it('should handle non-Error instance in getWorkItemTrackingApi', async () => { mockGetWorkItemTrackingApi.mockRejectedValue('String error'); - await expect(client.getWorkItemTrackingApi()).rejects.toThrow('Failed to get Work Item Tracking API: Unknown error'); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + 'Failed to get Work Item Tracking API: Unknown error', + ); }); - + it('should handle error in getBuildApi', async () => { mockGetBuildApi.mockRejectedValue(new Error('Build API Error')); - await expect(client.getBuildApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getBuildApi()).rejects.toThrow('Failed to get Build API'); + await expect(client.getBuildApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getBuildApi()).rejects.toThrow( + 'Failed to get Build API', + ); }); - + it('should handle non-Error instance in getBuildApi', async () => { mockGetBuildApi.mockRejectedValue('String error'); - await expect(client.getBuildApi()).rejects.toThrow('Failed to get Build API: Unknown error'); + await expect(client.getBuildApi()).rejects.toThrow( + 'Failed to get Build API: Unknown error', + ); }); - + it('should handle error in getTestApi', async () => { mockGetTestApi.mockRejectedValue(new Error('Test API Error')); - await expect(client.getTestApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTestApi()).rejects.toThrow('Failed to get Test API'); + await expect(client.getTestApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTestApi()).rejects.toThrow( + 'Failed to get Test API', + ); }); - + it('should handle non-Error instance in getTestApi', async () => { mockGetTestApi.mockRejectedValue('String error'); - await expect(client.getTestApi()).rejects.toThrow('Failed to get Test API: Unknown error'); + await expect(client.getTestApi()).rejects.toThrow( + 'Failed to get Test API: Unknown error', + ); }); - + it('should handle error in getReleaseApi', async () => { mockGetReleaseApi.mockRejectedValue(new Error('Release API Error')); - await expect(client.getReleaseApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getReleaseApi()).rejects.toThrow('Failed to get Release API'); + await expect(client.getReleaseApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getReleaseApi()).rejects.toThrow( + 'Failed to get Release API', + ); }); - + it('should handle non-Error instance in getReleaseApi', async () => { mockGetReleaseApi.mockRejectedValue('String error'); - await expect(client.getReleaseApi()).rejects.toThrow('Failed to get Release API: Unknown error'); + await expect(client.getReleaseApi()).rejects.toThrow( + 'Failed to get Release API: Unknown error', + ); }); - + it('should handle error in getTaskAgentApi', async () => { mockGetTaskAgentApi.mockRejectedValue(new Error('Task Agent API Error')); - await expect(client.getTaskAgentApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskAgentApi()).rejects.toThrow('Failed to get Task Agent API'); + await expect(client.getTaskAgentApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTaskAgentApi()).rejects.toThrow( + 'Failed to get Task Agent API', + ); }); - + it('should handle non-Error instance in getTaskAgentApi', async () => { mockGetTaskAgentApi.mockRejectedValue('String error'); - await expect(client.getTaskAgentApi()).rejects.toThrow('Failed to get Task Agent API: Unknown error'); + await expect(client.getTaskAgentApi()).rejects.toThrow( + 'Failed to get Task Agent API: Unknown error', + ); }); - + it('should handle error in getTaskApi', async () => { mockGetTaskApi.mockRejectedValue(new Error('Task API Error')); - await expect(client.getTaskApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskApi()).rejects.toThrow('Failed to get Task API'); + await expect(client.getTaskApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTaskApi()).rejects.toThrow( + 'Failed to get Task API', + ); }); - + it('should handle non-Error instance in getTaskApi', async () => { mockGetTaskApi.mockRejectedValue('String error'); - await expect(client.getTaskApi()).rejects.toThrow('Failed to get Task API: Unknown error'); + await expect(client.getTaskApi()).rejects.toThrow( + 'Failed to get Task API: Unknown error', + ); }); }); describe('isAuthenticated', () => { - it('should return true if Core API is accessible', async () => { + it('should return true if client is accessible', async () => { const isAuth = await client.isAuthenticated(); expect(isAuth).toBe(true); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); + expect(MockSharedClient).toHaveBeenCalledTimes(1); }); - it('should return false if Core API is not accessible', async () => { - mockCreateAuthenticatedClient.mockRejectedValueOnce(new Error('API Error')); - const isAuth = await client.isAuthenticated(); - expect(isAuth).toBe(false); - expect(mockCreateAuthenticatedClient).toHaveBeenCalledTimes(1); - }); - - it('should return false for any error type in isAuthenticated', async () => { - mockCreateAuthenticatedClient.mockRejectedValueOnce(new AzureDevOpsError('Auth Error')); + it('should return false if client is not accessible', async () => { + mockGetWebApiClient.mockRejectedValueOnce(new Error('API Error')); const isAuth = await client.isAuthenticated(); expect(isAuth).toBe(false); }); }); -}); \ No newline at end of file +}); diff --git a/src/api/client.ts b/src/shared/api/client.ts similarity index 80% rename from src/api/client.ts rename to src/shared/api/client.ts index f911810..7b5e80d 100644 --- a/src/api/client.ts +++ b/src/shared/api/client.ts @@ -7,8 +7,9 @@ import { ITestApi } from 'azure-devops-node-api/TestApi'; import { IReleaseApi } from 'azure-devops-node-api/ReleaseApi'; import { ITaskAgentApi } from 'azure-devops-node-api/TaskAgentApi'; import { ITaskApi } from 'azure-devops-node-api/TaskApi'; -import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../common/errors'; -import { createAuthenticatedClient } from './auth'; +import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../errors'; +import { AuthenticationMethod } from '../auth'; +import { AzureDevOpsClient as SharedClient } from '../auth/client-factory'; export interface AzureDevOpsClientConfig { orgUrl: string; @@ -17,7 +18,7 @@ export interface AzureDevOpsClientConfig { /** * Azure DevOps Client - * + * * Provides access to Azure DevOps APIs */ export class AzureDevOpsClient { @@ -30,7 +31,7 @@ export class AzureDevOpsClient { /** * Get the authenticated Azure DevOps client - * + * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -38,7 +39,12 @@ export class AzureDevOpsClient { if (!this.clientPromise) { this.clientPromise = (async () => { try { - return await createAuthenticatedClient(this.config); + const sharedClient = new SharedClient({ + method: AuthenticationMethod.PersonalAccessToken, + organizationUrl: this.config.orgUrl, + personalAccessToken: this.config.pat, + }); + return await sharedClient.getWebApiClient(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { @@ -46,9 +52,9 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Authentication failed: ${error.message}` - : 'Authentication failed: Unknown error' + error instanceof Error + ? `Authentication failed: ${error.message}` + : 'Authentication failed: Unknown error', ); } })(); @@ -58,7 +64,7 @@ export class AzureDevOpsClient { /** * Check if the client is authenticated - * + * * @returns True if the client is authenticated */ public async isAuthenticated(): Promise { @@ -73,7 +79,7 @@ export class AzureDevOpsClient { /** * Get the Core API - * + * * @returns The Core API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -88,16 +94,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Core API: ${error.message}` - : 'Failed to get Core API: Unknown error' + error instanceof Error + ? `Failed to get Core API: ${error.message}` + : 'Failed to get Core API: Unknown error', ); } } /** * Get the Git API - * + * * @returns The Git API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -112,16 +118,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Git API: ${error.message}` - : 'Failed to get Git API: Unknown error' + error instanceof Error + ? `Failed to get Git API: ${error.message}` + : 'Failed to get Git API: Unknown error', ); } } /** * Get the Work Item Tracking API - * + * * @returns The Work Item Tracking API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -136,16 +142,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Work Item Tracking API: ${error.message}` - : 'Failed to get Work Item Tracking API: Unknown error' + error instanceof Error + ? `Failed to get Work Item Tracking API: ${error.message}` + : 'Failed to get Work Item Tracking API: Unknown error', ); } } /** * Get the Build API - * + * * @returns The Build API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -160,16 +166,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Build API: ${error.message}` - : 'Failed to get Build API: Unknown error' + error instanceof Error + ? `Failed to get Build API: ${error.message}` + : 'Failed to get Build API: Unknown error', ); } } /** * Get the Test API - * + * * @returns The Test API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -184,16 +190,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Test API: ${error.message}` - : 'Failed to get Test API: Unknown error' + error instanceof Error + ? `Failed to get Test API: ${error.message}` + : 'Failed to get Test API: Unknown error', ); } } /** * Get the Release API - * + * * @returns The Release API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -208,16 +214,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Release API: ${error.message}` - : 'Failed to get Release API: Unknown error' + error instanceof Error + ? `Failed to get Release API: ${error.message}` + : 'Failed to get Release API: Unknown error', ); } } /** * Get the Task Agent API - * + * * @returns The Task Agent API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -232,16 +238,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Task Agent API: ${error.message}` - : 'Failed to get Task Agent API: Unknown error' + error instanceof Error + ? `Failed to get Task Agent API: ${error.message}` + : 'Failed to get Task Agent API: Unknown error', ); } } /** * Get the Task API - * + * * @returns The Task API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -256,10 +262,10 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Task API: ${error.message}` - : 'Failed to get Task API: Unknown error' + error instanceof Error + ? `Failed to get Task API: ${error.message}` + : 'Failed to get Task API: Unknown error', ); } } -} \ No newline at end of file +} diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts new file mode 100644 index 0000000..4f1cce4 --- /dev/null +++ b/src/shared/api/index.ts @@ -0,0 +1 @@ +export * from './client'; diff --git a/src/shared/auth/auth-factory.test.ts b/src/shared/auth/auth-factory.test.ts new file mode 100644 index 0000000..668d47a --- /dev/null +++ b/src/shared/auth/auth-factory.test.ts @@ -0,0 +1,303 @@ +import { WebApi } from 'azure-devops-node-api'; +import { + AuthConfig, + AuthenticationMethod, + createAuthClient, +} from './auth-factory'; +import { AzureDevOpsAuthenticationError } from '../errors/azure-devops-errors'; +import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; + +// Mock the azure-devops-node-api module +jest.mock('azure-devops-node-api', () => { + const mockGetResourceAreas = jest.fn().mockResolvedValue([]); + const mockGetLocationsApi = jest.fn().mockResolvedValue({ + getResourceAreas: mockGetResourceAreas, + }); + + return { + WebApi: jest.fn().mockImplementation(() => ({ + getLocationsApi: mockGetLocationsApi, + })), + getPersonalAccessTokenHandler: jest.fn().mockReturnValue({}), + getBearerHandler: jest.fn().mockReturnValue({}), + BearerCredentialHandler: jest.fn().mockImplementation(() => ({})), + }; +}); + +// Mock Azure Identity +jest.mock('@azure/identity', () => { + return { + DefaultAzureCredential: jest.fn().mockImplementation(() => ({ + getToken: jest.fn().mockResolvedValue({ token: 'mock-azure-token' }), + })), + AzureCliCredential: jest.fn().mockImplementation(() => ({ + getToken: jest.fn().mockResolvedValue({ token: 'mock-cli-token' }), + })), + }; +}); + +// Use the jest mock types directly +const WebApiMock = WebApi as unknown as jest.Mock; +const DefaultAzureCredentialMock = DefaultAzureCredential as unknown as jest.Mock; +const AzureCliCredentialMock = AzureCliCredential as unknown as jest.Mock; + +describe('auth-factory', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset the WebApi mock to a working implementation for each test + WebApiMock.mockImplementation(() => ({ + getLocationsApi: jest.fn().mockResolvedValue({ + getResourceAreas: jest.fn().mockResolvedValue([]), + }), + })); + }); + + describe('createAuthClient', () => { + it('should throw error if PAT is missing for PAT authentication', async () => { + await expect( + createAuthClient({ + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: '', + organizationUrl: 'https://dev.azure.com/org', + }), + ).rejects.toThrow(AzureDevOpsAuthenticationError); + }); + + it('should throw error if organization URL is missing', async () => { + await expect( + createAuthClient({ + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'validpat', + organizationUrl: '', + }), + ).rejects.toThrow(AzureDevOpsAuthenticationError); + }); + + it('should create WebApi client with correct configuration', async () => { + // Set up a mock implementation for this specific test + const mockGetResourceAreas = jest.fn().mockResolvedValue([]); + const mockGetLocationsApi = jest.fn().mockResolvedValue({ + getResourceAreas: mockGetResourceAreas, + }); + + // Clear previous mock implementation and set new one + WebApiMock.mockImplementation(() => ({ + getLocationsApi: mockGetLocationsApi, + })); + + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'validpat', + organizationUrl: 'https://dev.azure.com/org', + }; + + const client = await createAuthClient(config); + + expect(WebApiMock).toHaveBeenCalledTimes(1); + expect(mockGetLocationsApi).toHaveBeenCalledTimes(1); + expect(client).toBeDefined(); + }); + + it('should throw authentication error if API call fails', async () => { + // Create a mock implementation that fails + const mockGetResourceAreas = jest + .fn() + .mockRejectedValue(new Error('API Error')); + const mockGetLocationsApi = jest.fn().mockResolvedValue({ + getResourceAreas: mockGetResourceAreas, + }); + + // Set up the mock to throw an error from getResourceAreas + WebApiMock.mockImplementation(() => ({ + getLocationsApi: mockGetLocationsApi, + })); + + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'validpat', + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error for unsupported authentication method', async () => { + const config: AuthConfig = { + method: 'unsupported-method' as AuthenticationMethod, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should create client using AzureIdentity authentication', async () => { + const config: AuthConfig = { + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/org', + }; + + const client = await createAuthClient(config); + + expect(DefaultAzureCredentialMock).toHaveBeenCalledTimes(1); + expect(WebApiMock).toHaveBeenCalledTimes(1); + expect(client).toBeDefined(); + }); + + it('should throw error if AzureIdentity token acquisition fails', async () => { + DefaultAzureCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockRejectedValue(new Error('Token acquisition failed')), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureIdentity token is null', async () => { + DefaultAzureCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue({ token: null }), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureIdentity token response is undefined', async () => { + DefaultAzureCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue(undefined), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureIdentity token is empty string', async () => { + DefaultAzureCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue({ token: '' }), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should create client using AzureCli authentication', async () => { + const config: AuthConfig = { + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/org', + }; + + const client = await createAuthClient(config); + + expect(AzureCliCredentialMock).toHaveBeenCalledTimes(1); + expect(WebApiMock).toHaveBeenCalledTimes(1); + expect(client).toBeDefined(); + }); + + it('should throw error if AzureCli token acquisition fails', async () => { + AzureCliCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockRejectedValue(new Error('CLI token acquisition failed')), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureCli token is null', async () => { + AzureCliCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue({ token: null }), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureCli token response is undefined', async () => { + AzureCliCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue(undefined), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should throw error if AzureCli token is empty string', async () => { + AzureCliCredentialMock.mockImplementationOnce(() => ({ + getToken: jest.fn().mockResolvedValue({ token: '' }), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should handle non-Error exception in createAuthClient', async () => { + // Mock the implementation to throw during the connection test + WebApiMock.mockImplementationOnce(() => ({ + getLocationsApi: jest.fn().mockImplementation(() => { + throw 'String error'; + }), + })); + + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'validpat', + organizationUrl: 'https://dev.azure.com/org', + }; + + await expect(createAuthClient(config)).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + }); +}); \ No newline at end of file diff --git a/src/auth/auth-factory.ts b/src/shared/auth/auth-factory.ts similarity index 87% rename from src/auth/auth-factory.ts rename to src/shared/auth/auth-factory.ts index c5b77f7..ca82f66 100644 --- a/src/auth/auth-factory.ts +++ b/src/shared/auth/auth-factory.ts @@ -1,7 +1,7 @@ import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api'; import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearertoken'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; -import { AzureDevOpsAuthenticationError } from '../common/errors'; +import { AzureDevOpsAuthenticationError } from '../errors'; /** * Authentication methods supported by the Azure DevOps client @@ -11,16 +11,16 @@ export enum AuthenticationMethod { * Personal Access Token authentication */ PersonalAccessToken = 'pat', - + /** * Azure Identity authentication (DefaultAzureCredential) */ AzureIdentity = 'azure-identity', - + /** * Azure CLI authentication (AzureCliCredential) */ - AzureCli = 'azure-cli' + AzureCli = 'azure-cli', } /** @@ -31,12 +31,12 @@ export interface AuthConfig { * Authentication method to use */ method: AuthenticationMethod; - + /** * Organization URL (e.g., https://dev.azure.com/myorg) */ organizationUrl: string; - + /** * Personal Access Token for Azure DevOps (required for PAT authentication) */ @@ -74,7 +74,9 @@ export async function createAuthClient(config: AuthConfig): Promise { client = await createAzureCliClient(config); break; default: - throw new AzureDevOpsAuthenticationError(`Unsupported authentication method: ${config.method}`); + throw new AzureDevOpsAuthenticationError( + `Unsupported authentication method: ${config.method}`, + ); } // Test the connection @@ -87,21 +89,23 @@ export async function createAuthClient(config: AuthConfig): Promise { throw error; } throw new AzureDevOpsAuthenticationError( - `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}` + `Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Creates a client using Personal Access Token authentication - * + * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If PAT is missing or authentication fails */ async function createPatClient(config: AuthConfig): Promise { if (!config.personalAccessToken) { - throw new AzureDevOpsAuthenticationError('Personal Access Token (PAT) is required'); + throw new AzureDevOpsAuthenticationError( + 'Personal Access Token is required', + ); } // Create authentication handler using PAT @@ -113,7 +117,7 @@ async function createPatClient(config: AuthConfig): Promise { /** * Creates a client using DefaultAzureCredential authentication - * + * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If token acquisition fails @@ -122,29 +126,31 @@ async function createAzureIdentityClient(config: AuthConfig): Promise { try { // Create DefaultAzureCredential const credential = new DefaultAzureCredential(); - + // Get token for Azure DevOps - const token = await credential.getToken(`${AZURE_DEVOPS_RESOURCE_ID}/.default`); - + const token = await credential.getToken( + `${AZURE_DEVOPS_RESOURCE_ID}/.default`, + ); + if (!token || !token.token) { throw new Error('Failed to acquire token'); } - + // Create bearer token handler const authHandler = new BearerCredentialHandler(token.token); - + // Create API client with the auth handler return new WebApi(config.organizationUrl, authHandler); } catch (error) { throw new AzureDevOpsAuthenticationError( - `Failed to acquire Azure Identity token: ${error instanceof Error ? error.message : String(error)}` + `Failed to acquire Azure Identity token: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Creates a client using AzureCliCredential authentication - * + * * @param config Authentication configuration * @returns Authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If token acquisition fails @@ -153,22 +159,24 @@ async function createAzureCliClient(config: AuthConfig): Promise { try { // Create AzureCliCredential const credential = new AzureCliCredential(); - + // Get token for Azure DevOps - const token = await credential.getToken(`${AZURE_DEVOPS_RESOURCE_ID}/.default`); - + const token = await credential.getToken( + `${AZURE_DEVOPS_RESOURCE_ID}/.default`, + ); + if (!token || !token.token) { throw new Error('Failed to acquire token'); } - + // Create bearer token handler const authHandler = new BearerCredentialHandler(token.token); - + // Create API client with the auth handler return new WebApi(config.organizationUrl, authHandler); } catch (error) { throw new AzureDevOpsAuthenticationError( - `Failed to acquire Azure CLI token: ${error instanceof Error ? error.message : String(error)}` + `Failed to acquire Azure CLI token: ${error instanceof Error ? error.message : String(error)}`, ); } -} \ No newline at end of file +} diff --git a/src/shared/auth/client-factory.test.ts b/src/shared/auth/client-factory.test.ts new file mode 100644 index 0000000..6416ed0 --- /dev/null +++ b/src/shared/auth/client-factory.test.ts @@ -0,0 +1,383 @@ +import { WebApi } from 'azure-devops-node-api'; +import { AzureDevOpsClient } from './client-factory'; +import { + AzureDevOpsAuthenticationError, + AzureDevOpsError, +} from '../errors/azure-devops-errors'; +import { + AuthConfig, + AuthenticationMethod, + createAuthClient, +} from './auth-factory'; + +// Mock the azure-devops-node-api module +jest.mock('azure-devops-node-api'); +// Mock the auth module +jest.mock('./auth-factory'); + +const MockWebApi = WebApi as jest.MockedClass; +const mockCreateAuthClient = createAuthClient as jest.MockedFunction< + typeof createAuthClient +>; + +describe('AzureDevOpsClient', () => { + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + personalAccessToken: 'validpat', + organizationUrl: 'https://dev.azure.com/org', + }; + + let client: AzureDevOpsClient; + let mockWebApiInstance: any; + let mockGetCoreApi: jest.Mock; + let mockGetGitApi: jest.Mock; + let mockGetWorkItemTrackingApi: jest.Mock; + let mockGetBuildApi: jest.Mock; + let mockGetTestApi: jest.Mock; + let mockGetReleaseApi: jest.Mock; + let mockGetTaskAgentApi: jest.Mock; + let mockGetTaskApi: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup mock API methods + mockGetCoreApi = jest.fn().mockResolvedValue({}); + mockGetGitApi = jest.fn().mockResolvedValue({}); + mockGetWorkItemTrackingApi = jest.fn().mockResolvedValue({}); + mockGetBuildApi = jest.fn().mockResolvedValue({}); + mockGetTestApi = jest.fn().mockResolvedValue({}); + mockGetReleaseApi = jest.fn().mockResolvedValue({}); + mockGetTaskAgentApi = jest.fn().mockResolvedValue({}); + mockGetTaskApi = jest.fn().mockResolvedValue({}); + + mockWebApiInstance = { + getCoreApi: mockGetCoreApi, + getGitApi: mockGetGitApi, + getWorkItemTrackingApi: mockGetWorkItemTrackingApi, + getBuildApi: mockGetBuildApi, + getTestApi: mockGetTestApi, + getReleaseApi: mockGetReleaseApi, + getTaskAgentApi: mockGetTaskAgentApi, + getTaskApi: mockGetTaskApi, + }; + + MockWebApi.mockImplementation(() => mockWebApiInstance); + + // Mock the createAuthClient function to return our mockWebApiInstance + mockCreateAuthClient.mockResolvedValue(mockWebApiInstance); + + client = new AzureDevOpsClient(config); + }); + + describe('getClient', () => { + it('should create WebApi client only once', async () => { + await client.getCoreApi(); + await client.getCoreApi(); + + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + expect(mockCreateAuthClient).toHaveBeenCalledWith(config); + }); + + it('should re-use the cached client for multiple API calls', async () => { + await client.getCoreApi(); + await client.getGitApi(); + await client.getWorkItemTrackingApi(); + + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + + it('should handle authentication errors when creating client', async () => { + mockCreateAuthClient.mockRejectedValueOnce( + new AzureDevOpsAuthenticationError('Authentication failed'), + ); + + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + + it('should handle generic errors when creating client', async () => { + mockCreateAuthClient.mockRejectedValueOnce(new Error('Network error')); + + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + + it('should handle non-Error instances when creating client', async () => { + mockCreateAuthClient.mockRejectedValueOnce('String error'); + + await expect(client.getCoreApi()).rejects.toThrow( + 'Authentication failed: Unknown error', + ); + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + }); + + describe('API client getters', () => { + it('should get Core API client', async () => { + const coreApi = await client.getCoreApi(); + expect(coreApi).toBeDefined(); + expect(mockGetCoreApi).toHaveBeenCalledTimes(1); + }); + + it('should get Git API client', async () => { + const gitApi = await client.getGitApi(); + expect(gitApi).toBeDefined(); + expect(mockGetGitApi).toHaveBeenCalledTimes(1); + }); + + it('should get Work Item Tracking API client', async () => { + const witApi = await client.getWorkItemTrackingApi(); + expect(witApi).toBeDefined(); + expect(mockGetWorkItemTrackingApi).toHaveBeenCalledTimes(1); + }); + + it('should get Build API client', async () => { + const buildApi = await client.getBuildApi(); + expect(buildApi).toBeDefined(); + expect(mockGetBuildApi).toHaveBeenCalledTimes(1); + }); + + it('should get Test API client', async () => { + const testApi = await client.getTestApi(); + expect(testApi).toBeDefined(); + expect(mockGetTestApi).toHaveBeenCalledTimes(1); + }); + + it('should get Release API client', async () => { + const releaseApi = await client.getReleaseApi(); + expect(releaseApi).toBeDefined(); + expect(mockGetReleaseApi).toHaveBeenCalledTimes(1); + }); + + it('should get Task Agent API client', async () => { + const taskAgentApi = await client.getTaskAgentApi(); + expect(taskAgentApi).toBeDefined(); + expect(mockGetTaskAgentApi).toHaveBeenCalledTimes(1); + }); + + it('should get Task API client', async () => { + const taskApi = await client.getTaskApi(); + expect(taskApi).toBeDefined(); + expect(mockGetTaskApi).toHaveBeenCalledTimes(1); + }); + + it('should throw authentication error if API call fails', async () => { + mockGetCoreApi.mockRejectedValue(new Error('API Error')); + await expect(client.getCoreApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + }); + + it('should rethrow AzureDevOpsError if API call fails with that error type', async () => { + const apiError = new AzureDevOpsError('API Error'); + mockGetCoreApi.mockRejectedValue(apiError); + await expect(client.getCoreApi()).rejects.toThrow(apiError); + }); + + it('should handle error in getGitApi', async () => { + mockGetGitApi.mockRejectedValue(new Error('Git API Error')); + await expect(client.getGitApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getGitApi()).rejects.toThrow('Failed to get Git API'); + }); + + it('should handle non-Error instance in getGitApi', async () => { + mockGetGitApi.mockRejectedValue('String error'); + await expect(client.getGitApi()).rejects.toThrow( + 'Failed to get Git API: Unknown error', + ); + }); + + it('should handle error in getWorkItemTrackingApi', async () => { + mockGetWorkItemTrackingApi.mockRejectedValue( + new Error('Work Item API Error'), + ); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + 'Failed to get Work Item Tracking API', + ); + }); + + it('should handle non-Error instance in getWorkItemTrackingApi', async () => { + mockGetWorkItemTrackingApi.mockRejectedValue('String error'); + await expect(client.getWorkItemTrackingApi()).rejects.toThrow( + 'Failed to get Work Item Tracking API: Unknown error', + ); + }); + + it('should handle error in getBuildApi', async () => { + mockGetBuildApi.mockRejectedValue(new Error('Build API Error')); + await expect(client.getBuildApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getBuildApi()).rejects.toThrow( + 'Failed to get Build API', + ); + }); + + it('should handle non-Error instance in getBuildApi', async () => { + mockGetBuildApi.mockRejectedValue('String error'); + await expect(client.getBuildApi()).rejects.toThrow( + 'Failed to get Build API: Unknown error', + ); + }); + + it('should handle error in getTestApi', async () => { + mockGetTestApi.mockRejectedValue(new Error('Test API Error')); + await expect(client.getTestApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTestApi()).rejects.toThrow( + 'Failed to get Test API', + ); + }); + + it('should handle non-Error instance in getTestApi', async () => { + mockGetTestApi.mockRejectedValue('String error'); + await expect(client.getTestApi()).rejects.toThrow( + 'Failed to get Test API: Unknown error', + ); + }); + + it('should handle error in getReleaseApi', async () => { + mockGetReleaseApi.mockRejectedValue(new Error('Release API Error')); + await expect(client.getReleaseApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getReleaseApi()).rejects.toThrow( + 'Failed to get Release API', + ); + }); + + it('should handle non-Error instance in getReleaseApi', async () => { + mockGetReleaseApi.mockRejectedValue('String error'); + await expect(client.getReleaseApi()).rejects.toThrow( + 'Failed to get Release API: Unknown error', + ); + }); + + it('should handle error in getTaskAgentApi', async () => { + mockGetTaskAgentApi.mockRejectedValue(new Error('Task Agent API Error')); + await expect(client.getTaskAgentApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTaskAgentApi()).rejects.toThrow( + 'Failed to get Task Agent API', + ); + }); + + it('should handle non-Error instance in getTaskAgentApi', async () => { + mockGetTaskAgentApi.mockRejectedValue('String error'); + await expect(client.getTaskAgentApi()).rejects.toThrow( + 'Failed to get Task Agent API: Unknown error', + ); + }); + + it('should handle error in getTaskApi', async () => { + mockGetTaskApi.mockRejectedValue(new Error('Task API Error')); + await expect(client.getTaskApi()).rejects.toThrow( + AzureDevOpsAuthenticationError, + ); + await expect(client.getTaskApi()).rejects.toThrow( + 'Failed to get Task API', + ); + }); + + it('should handle non-Error instance in getTaskApi', async () => { + mockGetTaskApi.mockRejectedValue('String error'); + await expect(client.getTaskApi()).rejects.toThrow( + 'Failed to get Task API: Unknown error', + ); + }); + }); + + describe('isAuthenticated', () => { + it('should return true if Core API is accessible', async () => { + const isAuth = await client.isAuthenticated(); + expect(isAuth).toBe(true); + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + + it('should return false if Core API is not accessible', async () => { + mockCreateAuthClient.mockRejectedValueOnce(new Error('API Error')); + const isAuth = await client.isAuthenticated(); + expect(isAuth).toBe(false); + expect(mockCreateAuthClient).toHaveBeenCalledTimes(1); + }); + + it('should return false for any error type in isAuthenticated', async () => { + mockCreateAuthClient.mockRejectedValueOnce( + new AzureDevOpsError('Auth Error'), + ); + const isAuth = await client.isAuthenticated(); + expect(isAuth).toBe(false); + }); + }); + + // Add tests from auth.test.ts for client functionality + describe('Authentication Method', () => { + it('should use PAT authentication method by default', () => { + const client = new AzureDevOpsClient({ + method: AuthenticationMethod.PersonalAccessToken, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'test-pat', + }); + + expect(client['config'].method).toBe( + AuthenticationMethod.PersonalAccessToken, + ); + }); + + it('should detect Azure Identity auth method', () => { + const client = new AzureDevOpsClient({ + method: AuthenticationMethod.AzureIdentity, + organizationUrl: 'https://dev.azure.com/test', + }); + + expect(client['config'].method).toBe(AuthenticationMethod.AzureIdentity); + }); + + it('should detect Azure CLI auth method', () => { + const client = new AzureDevOpsClient({ + method: AuthenticationMethod.AzureCli, + organizationUrl: 'https://dev.azure.com/test', + }); + + expect(client['config'].method).toBe(AuthenticationMethod.AzureCli); + }); + }); + + describe('Client Creation', () => { + it('should create client with the right configuration', () => { + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'test-pat', + }; + + const client = new AzureDevOpsClient(config); + expect(client).toBeDefined(); + expect(client['config']).toEqual(config); + }); + + it('should get WebApi client', async () => { + const config: AuthConfig = { + method: AuthenticationMethod.PersonalAccessToken, + organizationUrl: 'https://dev.azure.com/test', + personalAccessToken: 'test-pat', + }; + + const client = new AzureDevOpsClient(config); + const webApi = await client.getWebApiClient(); + expect(webApi).toBeDefined(); + }); + }); +}); diff --git a/src/auth/client-factory.ts b/src/shared/auth/client-factory.ts similarity index 84% rename from src/auth/client-factory.ts rename to src/shared/auth/client-factory.ts index f3d5ac9..2e82ed4 100644 --- a/src/auth/client-factory.ts +++ b/src/shared/auth/client-factory.ts @@ -7,12 +7,12 @@ import { ITestApi } from 'azure-devops-node-api/TestApi'; import { IReleaseApi } from 'azure-devops-node-api/ReleaseApi'; import { ITaskAgentApi } from 'azure-devops-node-api/TaskAgentApi'; import { ITaskApi } from 'azure-devops-node-api/TaskApi'; -import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../common/errors'; +import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../errors'; import { AuthConfig, createAuthClient } from './auth-factory'; /** * Azure DevOps Client - * + * * Provides access to Azure DevOps APIs using the configured authentication method */ export class AzureDevOpsClient { @@ -21,7 +21,7 @@ export class AzureDevOpsClient { /** * Creates a new Azure DevOps client - * + * * @param config Authentication configuration */ constructor(config: AuthConfig) { @@ -30,7 +30,7 @@ export class AzureDevOpsClient { /** * Get the authenticated Azure DevOps client - * + * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -46,9 +46,9 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Authentication failed: ${error.message}` - : 'Authentication failed: Unknown error' + error instanceof Error + ? `Authentication failed: ${error.message}` + : 'Authentication failed: Unknown error', ); } })(); @@ -58,7 +58,7 @@ export class AzureDevOpsClient { /** * Get the underlying WebApi client - * + * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -68,7 +68,7 @@ export class AzureDevOpsClient { /** * Check if the client is authenticated - * + * * @returns True if the client is authenticated */ public async isAuthenticated(): Promise { @@ -83,7 +83,7 @@ export class AzureDevOpsClient { /** * Get the Core API - * + * * @returns The Core API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -98,16 +98,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Core API: ${error.message}` - : 'Failed to get Core API: Unknown error' + error instanceof Error + ? `Failed to get Core API: ${error.message}` + : 'Failed to get Core API: Unknown error', ); } } /** * Get the Git API - * + * * @returns The Git API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -122,16 +122,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Git API: ${error.message}` - : 'Failed to get Git API: Unknown error' + error instanceof Error + ? `Failed to get Git API: ${error.message}` + : 'Failed to get Git API: Unknown error', ); } } /** * Get the Work Item Tracking API - * + * * @returns The Work Item Tracking API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -146,16 +146,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Work Item Tracking API: ${error.message}` - : 'Failed to get Work Item Tracking API: Unknown error' + error instanceof Error + ? `Failed to get Work Item Tracking API: ${error.message}` + : 'Failed to get Work Item Tracking API: Unknown error', ); } } /** * Get the Build API - * + * * @returns The Build API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -170,16 +170,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Build API: ${error.message}` - : 'Failed to get Build API: Unknown error' + error instanceof Error + ? `Failed to get Build API: ${error.message}` + : 'Failed to get Build API: Unknown error', ); } } /** * Get the Test API - * + * * @returns The Test API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -194,16 +194,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Test API: ${error.message}` - : 'Failed to get Test API: Unknown error' + error instanceof Error + ? `Failed to get Test API: ${error.message}` + : 'Failed to get Test API: Unknown error', ); } } /** * Get the Release API - * + * * @returns The Release API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -218,16 +218,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Release API: ${error.message}` - : 'Failed to get Release API: Unknown error' + error instanceof Error + ? `Failed to get Release API: ${error.message}` + : 'Failed to get Release API: Unknown error', ); } } /** * Get the Task Agent API - * + * * @returns The Task Agent API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -242,16 +242,16 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Task Agent API: ${error.message}` - : 'Failed to get Task Agent API: Unknown error' + error instanceof Error + ? `Failed to get Task Agent API: ${error.message}` + : 'Failed to get Task Agent API: Unknown error', ); } } /** * Get the Task API - * + * * @returns The Task API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ @@ -266,10 +266,10 @@ export class AzureDevOpsClient { } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( - error instanceof Error - ? `Failed to get Task API: ${error.message}` - : 'Failed to get Task API: Unknown error' + error instanceof Error + ? `Failed to get Task API: ${error.message}` + : 'Failed to get Task API: Unknown error', ); } } -} \ No newline at end of file +} diff --git a/src/auth/index.ts b/src/shared/auth/index.ts similarity index 65% rename from src/auth/index.ts rename to src/shared/auth/index.ts index 512c7fb..441c3c9 100644 --- a/src/auth/index.ts +++ b/src/shared/auth/index.ts @@ -1,6 +1,6 @@ /** * Authentication module for Azure DevOps - * + * * This module provides authentication functionality for Azure DevOps API. * It supports multiple authentication methods: * - Personal Access Token (PAT) @@ -8,5 +8,9 @@ * - Azure CLI (AzureCliCredential) */ -export { AuthenticationMethod, AuthConfig, createAuthClient } from './auth-factory'; -export { AzureDevOpsClient } from './client-factory'; \ No newline at end of file +export { + AuthenticationMethod, + AuthConfig, + createAuthClient, +} from './auth-factory'; +export { AzureDevOpsClient } from './client-factory'; diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts new file mode 100644 index 0000000..70fe043 --- /dev/null +++ b/src/shared/config/index.ts @@ -0,0 +1 @@ +export * from './version'; diff --git a/src/config/version.ts b/src/shared/config/version.ts similarity index 64% rename from src/config/version.ts rename to src/shared/config/version.ts index 6145186..6e482a5 100644 --- a/src/config/version.ts +++ b/src/shared/config/version.ts @@ -1,4 +1,4 @@ /** * Current version of the Azure DevOps MCP server */ -export const VERSION = '0.1.0'; \ No newline at end of file +export const VERSION = '0.1.0'; diff --git a/tests/unit/api/errors.test.ts b/src/shared/errors/azure-devops-errors.test.ts similarity index 54% rename from tests/unit/api/errors.test.ts rename to src/shared/errors/azure-devops-errors.test.ts index 8f02bb2..457aca7 100644 --- a/tests/unit/api/errors.test.ts +++ b/src/shared/errors/azure-devops-errors.test.ts @@ -6,8 +6,8 @@ import { AzureDevOpsPermissionError, AzureDevOpsRateLimitError, isAzureDevOpsError, - formatAzureDevOpsError -} from '../../../src/common/errors'; + formatAzureDevOpsError, +} from './azure-devops-errors'; describe('Azure DevOps Errors', () => { describe('AzureDevOpsError', () => { @@ -33,7 +33,10 @@ describe('Azure DevOps Errors', () => { describe('AzureDevOpsValidationError', () => { it('should create validation error with response', () => { const response = { status: 400, data: { message: 'Invalid input' } }; - const error = new AzureDevOpsValidationError('Validation failed', response); + const error = new AzureDevOpsValidationError( + 'Validation failed', + response, + ); expect(error.message).toBe('Validation failed'); expect(error.name).toBe('AzureDevOpsValidationError'); expect(error.response).toBe(response); @@ -42,7 +45,10 @@ describe('Azure DevOps Errors', () => { }); it('should create validation error without response', () => { - const error = new AzureDevOpsValidationError('Validation failed', undefined); + const error = new AzureDevOpsValidationError( + 'Validation failed', + undefined, + ); expect(error.message).toBe('Validation failed'); expect(error.response).toBeUndefined(); }); @@ -70,100 +76,127 @@ describe('Azure DevOps Errors', () => { describe('AzureDevOpsRateLimitError', () => { it('should create rate limit error with reset time', () => { - const resetAt = new Date(); - const error = new AzureDevOpsRateLimitError('Rate limit exceeded', resetAt); + const resetTime = new Date(Date.now() + 60000); // 1 minute from now + const error = new AzureDevOpsRateLimitError( + 'Rate limit exceeded', + resetTime, + ); expect(error.message).toBe('Rate limit exceeded'); expect(error.name).toBe('AzureDevOpsRateLimitError'); - expect(error.resetAt).toBe(resetAt); + expect(error.resetAt).toBe(resetTime); expect(error instanceof AzureDevOpsError).toBe(true); expect(error instanceof AzureDevOpsRateLimitError).toBe(true); }); it('should create rate limit error with default reset time', () => { - const error = new AzureDevOpsRateLimitError('Rate limit exceeded', new Date(0)); + const defaultResetAt = new Date(Date.now() + 30000); // Default should be 30 seconds from now + + const error = new AzureDevOpsRateLimitError( + 'Rate limit exceeded', + defaultResetAt, + ); expect(error.message).toBe('Rate limit exceeded'); - expect(error.resetAt.getTime()).toBe(0); + expect(error.resetAt).toBeInstanceOf(Date); + // Should be roughly 30 seconds in the future + expect(error.resetAt.getTime()).toBeGreaterThan(Date.now()); + expect(error.resetAt.getTime()).toBeLessThan(Date.now() + 60000); }); }); describe('isAzureDevOpsError', () => { it('should return true for Azure DevOps errors', () => { - expect(isAzureDevOpsError(new AzureDevOpsError('Test'))).toBe(true); - expect(isAzureDevOpsError(new AzureDevOpsAuthenticationError('Test'))).toBe(true); - expect(isAzureDevOpsError(new AzureDevOpsValidationError('Test', undefined))).toBe(true); - expect(isAzureDevOpsError(new AzureDevOpsResourceNotFoundError('Test'))).toBe(true); - expect(isAzureDevOpsError(new AzureDevOpsPermissionError('Test'))).toBe(true); - expect(isAzureDevOpsError(new AzureDevOpsRateLimitError('Test', new Date(0)))).toBe(true); + expect(isAzureDevOpsError(new AzureDevOpsError('Error'))).toBe(true); + expect( + isAzureDevOpsError(new AzureDevOpsAuthenticationError('Auth error')), + ).toBe(true); + expect( + isAzureDevOpsError(new AzureDevOpsValidationError('Validation error')), + ).toBe(true); + expect( + isAzureDevOpsError(new AzureDevOpsResourceNotFoundError('Not found')), + ).toBe(true); + expect( + isAzureDevOpsError(new AzureDevOpsPermissionError('Permission denied')), + ).toBe(true); + expect( + isAzureDevOpsError( + new AzureDevOpsRateLimitError('Rate limit', new Date()), + ), + ).toBe(true); }); it('should return false for non-Azure DevOps errors', () => { - expect(isAzureDevOpsError(new Error('Test'))).toBe(false); - expect(isAzureDevOpsError({ message: 'Test' })).toBe(false); + expect(isAzureDevOpsError(new Error('Generic error'))).toBe(false); + expect(isAzureDevOpsError('string error')).toBe(false); expect(isAzureDevOpsError(null)).toBe(false); expect(isAzureDevOpsError(undefined)).toBe(false); + expect(isAzureDevOpsError(42)).toBe(false); + expect(isAzureDevOpsError({})).toBe(false); }); }); describe('formatAzureDevOpsError', () => { it('should format Azure DevOps error with name and message', () => { const error = new AzureDevOpsError('Test error'); - expect(formatAzureDevOpsError(error)).toBe('AzureDevOpsError: Test error'); + const formatted = formatAzureDevOpsError(error); + expect(formatted).toContain('AzureDevOpsError'); + expect(formatted).toContain('Test error'); }); it('should format validation error with response', () => { const response = { status: 400, data: { message: 'Invalid input' } }; - const error = new AzureDevOpsValidationError('Validation failed', response); - expect(formatAzureDevOpsError(error)).toBe( - 'AzureDevOpsValidationError: Validation failed\nResponse: {"status":400,"data":{"message":"Invalid input"}}' + const error = new AzureDevOpsValidationError( + 'Validation failed', + response, ); + const formatted = formatAzureDevOpsError(error); + expect(formatted).toContain('AzureDevOpsValidationError'); + expect(formatted).toContain('Validation failed'); + expect(formatted).toContain('400'); + expect(formatted).toContain('Invalid input'); }); it('should format validation error with null response', () => { const error = new AzureDevOpsValidationError('Validation failed', null); - expect(formatAzureDevOpsError(error)).toBe('AzureDevOpsValidationError: Validation failed'); + const formatted = formatAzureDevOpsError(error); + expect(formatted).toContain('AzureDevOpsValidationError'); + expect(formatted).toContain('Validation failed'); + expect(formatted).toContain('No response details available'); }); it('should format rate limit error with reset time', () => { - const resetAt = new Date('2024-01-01T00:00:00Z'); - const error = new AzureDevOpsRateLimitError('Rate limit exceeded', resetAt); - expect(formatAzureDevOpsError(error)).toBe( - 'AzureDevOpsRateLimitError: Rate limit exceeded\nReset at: 2024-01-01T00:00:00.000Z' + const resetTime = new Date(Date.now() + 60000); // 1 minute from now + const error = new AzureDevOpsRateLimitError( + 'Rate limit exceeded', + resetTime, ); + const formatted = formatAzureDevOpsError(error); + expect(formatted).toContain('AzureDevOpsRateLimitError'); + expect(formatted).toContain('Rate limit exceeded'); + expect(formatted).toContain(resetTime.toISOString()); }); it('should format non-Azure DevOps error', () => { - const error = new Error('Regular error'); - expect(formatAzureDevOpsError(error as AzureDevOpsError)).toBe('Error: Regular error'); + const error = new Error('Generic error'); + const formatted = formatAzureDevOpsError(error); + expect(formatted).toContain('Error'); + expect(formatted).toContain('Generic error'); }); it('should handle non-error objects', () => { - const objError = { name: 'TestError', message: 'Test' } as AzureDevOpsError; - const strError = 'string error' as unknown as AzureDevOpsError; - const nullError = null as unknown as AzureDevOpsError; - const undefinedError = undefined as unknown as AzureDevOpsError; - - expect(formatAzureDevOpsError(objError)).toBe('TestError: Test'); - expect(formatAzureDevOpsError(strError)).toBe('string error'); - expect(formatAzureDevOpsError(nullError)).toBe('null'); - expect(formatAzureDevOpsError(undefinedError)).toBe('undefined'); + const formatted = formatAzureDevOpsError('string error'); + expect(formatted).toContain('string error'); }); it('should handle objects with missing properties', () => { - const errorWithoutName = { message: 'No name' } as AzureDevOpsError; - const errorWithoutMessage = { name: 'NoMessage' } as AzureDevOpsError; - const emptyObject = {} as AzureDevOpsError; - - expect(formatAzureDevOpsError(errorWithoutName)).toBe('Unknown: No name'); - expect(formatAzureDevOpsError(errorWithoutMessage)).toBe('NoMessage: Unknown error'); - expect(formatAzureDevOpsError(emptyObject)).toBe('Unknown: Unknown error'); + const formatted = formatAzureDevOpsError({}); + expect(formatted).toContain('Unknown error'); }); it('should handle other primitive types', () => { - const numberError = 123 as unknown as AzureDevOpsError; - const booleanError = true as unknown as AzureDevOpsError; - - expect(formatAzureDevOpsError(numberError)).toBe('Unknown: Unknown error'); - expect(formatAzureDevOpsError(booleanError)).toBe('Unknown: Unknown error'); + expect(formatAzureDevOpsError(null)).toContain('null'); + expect(formatAzureDevOpsError(undefined)).toContain('undefined'); + expect(formatAzureDevOpsError(42)).toContain('42'); }); }); -}); \ No newline at end of file +}); diff --git a/src/common/errors.ts b/src/shared/errors/azure-devops-errors.ts similarity index 91% rename from src/common/errors.ts rename to src/shared/errors/azure-devops-errors.ts index fddf400..3bf2bed 100644 --- a/src/common/errors.ts +++ b/src/shared/errors/azure-devops-errors.ts @@ -1,7 +1,7 @@ /** * Base error class for Azure DevOps API errors. * All specific Azure DevOps errors should extend this class. - * + * * @class AzureDevOpsError * @extends {Error} */ @@ -15,7 +15,7 @@ export class AzureDevOpsError extends Error { /** * Error thrown when authentication with Azure DevOps fails. * This can occur due to invalid credentials, expired tokens, or network issues. - * + * * @class AzureDevOpsAuthenticationError * @extends {AzureDevOpsError} */ @@ -29,7 +29,7 @@ export class AzureDevOpsAuthenticationError extends AzureDevOpsError { /** * Error thrown when input validation fails. * This includes invalid parameters, malformed requests, or missing required fields. - * + * * @class AzureDevOpsValidationError * @extends {AzureDevOpsError} * @property {any} [response] - The raw response from the API containing validation details @@ -47,7 +47,7 @@ export class AzureDevOpsValidationError extends AzureDevOpsError { /** * Error thrown when a requested resource is not found. * This can occur when trying to access non-existent projects, repositories, or work items. - * + * * @class AzureDevOpsResourceNotFoundError * @extends {AzureDevOpsError} */ @@ -61,7 +61,7 @@ export class AzureDevOpsResourceNotFoundError extends AzureDevOpsError { /** * Error thrown when the user lacks permissions for an operation. * This occurs when trying to access or modify resources without proper authorization. - * + * * @class AzureDevOpsPermissionError * @extends {AzureDevOpsError} */ @@ -75,7 +75,7 @@ export class AzureDevOpsPermissionError extends AzureDevOpsError { /** * Error thrown when the API rate limit is exceeded. * Contains information about when the rate limit will reset. - * + * * @class AzureDevOpsRateLimitError * @extends {AzureDevOpsError} * @property {Date} resetAt - The time when the rate limit will reset @@ -93,10 +93,10 @@ export class AzureDevOpsRateLimitError extends AzureDevOpsError { /** * Helper function to check if an error is an Azure DevOps error. * Useful for type narrowing in catch blocks. - * + * * @param {any} error - The error to check * @returns {boolean} True if the error is an Azure DevOps error - * + * * @example * try { * // Some Azure DevOps operation @@ -115,10 +115,10 @@ export function isAzureDevOpsError(error: any): error is AzureDevOpsError { /** * Format an Azure DevOps error for display. * Provides a consistent error message format across different error types. - * + * * @param {any} error - The error to format * @returns {string} A formatted error message - * + * * @example * try { * // Some Azure DevOps operation @@ -131,23 +131,31 @@ export function formatAzureDevOpsError(error: any): string { if (error === null) { return 'null'; } - + if (error === undefined) { return 'undefined'; } - + if (typeof error === 'string') { return error; } - + + if (typeof error === 'number' || typeof error === 'boolean') { + return String(error); + } + // Handle error-like objects let message = `${error.name || 'Unknown'}: ${error.message || 'Unknown error'}`; - - if (error instanceof AzureDevOpsValidationError && error.response) { - message += `\nResponse: ${JSON.stringify(error.response)}`; + + if (error instanceof AzureDevOpsValidationError) { + if (error.response) { + message += `\nResponse: ${JSON.stringify(error.response)}`; + } else { + message += '\nNo response details available'; + } } else if (error instanceof AzureDevOpsRateLimitError) { message += `\nReset at: ${error.resetAt.toISOString()}`; } return message; -} \ No newline at end of file +} diff --git a/src/shared/errors/index.ts b/src/shared/errors/index.ts new file mode 100644 index 0000000..94cf76c --- /dev/null +++ b/src/shared/errors/index.ts @@ -0,0 +1 @@ +export * from './azure-devops-errors'; diff --git a/src/types/config.ts b/src/shared/types/config.ts similarity index 98% rename from src/types/config.ts rename to src/shared/types/config.ts index f005105..78b32d0 100644 --- a/src/types/config.ts +++ b/src/shared/types/config.ts @@ -8,25 +8,25 @@ export interface AzureDevOpsConfig { * The Azure DevOps organization URL (e.g., https://dev.azure.com/organization) */ organizationUrl: string; - + /** * Authentication method to use (pat, azure-identity, azure-cli) * @default 'pat' */ authMethod?: AuthenticationMethod; - + /** * Personal Access Token for authentication (required for PAT authentication) */ personalAccessToken?: string; - + /** * Optional default project to use when not specified */ defaultProject?: string; - + /** * Optional API version to use (defaults to latest) */ apiVersion?: string; -} \ No newline at end of file +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts new file mode 100644 index 0000000..f03c228 --- /dev/null +++ b/src/shared/types/index.ts @@ -0,0 +1 @@ +export * from './config'; diff --git a/src/test-azure-identity.js b/src/test-azure-identity.js deleted file mode 100644 index 4ee3540..0000000 --- a/src/test-azure-identity.js +++ /dev/null @@ -1,128 +0,0 @@ -"use strict"; -var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -}; -var __generator = (this && this.__generator) || function (thisArg, body) { - var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype); - return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; - function verb(n) { return function (v) { return step([n, v]); }; } - function step(op) { - if (f) throw new TypeError("Generator is already executing."); - while (g && (g = 0, op[0] && (_ = 0)), _) try { - if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; - if (y = 0, t) op = [op[0] & 2, t.value]; - switch (op[0]) { - case 0: case 1: t = op; break; - case 4: _.label++; return { value: op[1], done: false }; - case 5: _.label++; y = op[1]; op = [0]; continue; - case 7: op = _.ops.pop(); _.trys.pop(); continue; - default: - if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } - if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } - if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } - if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } - if (t[2]) _.ops.pop(); - _.trys.pop(); continue; - } - op = body.call(thisArg, _); - } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } - if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; - } -}; -Object.defineProperty(exports, "__esModule", { value: true }); -var identity_1 = require("@azure/identity"); -var azure_devops_node_api_1 = require("azure-devops-node-api"); -var bearertoken_1 = require("azure-devops-node-api/handlers/bearertoken"); -/** - * Test Azure Identity authentication with Azure DevOps - */ -function testAzureIdentity() { - return __awaiter(this, void 0, void 0, function () { - var defaultCredential, cliCredential, chainedCredential, error_1; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - _a.trys.push([0, 4, , 5]); - console.log('Testing Azure Identity authentication...'); - // Test DefaultAzureCredential - console.log('Testing DefaultAzureCredential...'); - defaultCredential = new identity_1.DefaultAzureCredential(); - return [4 /*yield*/, testCredential('DefaultAzureCredential', defaultCredential)]; - case 1: - _a.sent(); - // Test AzureCliCredential - console.log('Testing AzureCliCredential...'); - cliCredential = new identity_1.AzureCliCredential(); - return [4 /*yield*/, testCredential('AzureCliCredential', cliCredential)]; - case 2: - _a.sent(); - // Test ChainedTokenCredential with AzureCliCredential as fallback - console.log('Testing ChainedTokenCredential...'); - chainedCredential = new identity_1.ChainedTokenCredential(new identity_1.AzureCliCredential()); - return [4 /*yield*/, testCredential('ChainedTokenCredential', chainedCredential)]; - case 3: - _a.sent(); - return [3 /*break*/, 5]; - case 4: - error_1 = _a.sent(); - console.error('Error testing Azure Identity:', error_1); - return [3 /*break*/, 5]; - case 5: return [2 /*return*/]; - } - }); - }); -} -/** - * Test a specific credential with Azure DevOps - * - * @param name The name of the credential - * @param credential The credential to test - */ -function testCredential(name, credential) { - return __awaiter(this, void 0, void 0, function () { - var azureDevOpsResourceId, token, orgUrl, authHandler, connection, coreApi, projects, error_2; - return __generator(this, function (_a) { - switch (_a.label) { - case 0: - _a.trys.push([0, 5, , 6]); - azureDevOpsResourceId = '499b84ac-1321-427f-aa17-267ca6975798'; - return [4 /*yield*/, credential.getToken("".concat(azureDevOpsResourceId, "/.default"))]; - case 1: - token = _a.sent(); - console.log("".concat(name, " token acquired:"), token ? 'Success' : 'Failed'); - if (!token) return [3 /*break*/, 4]; - orgUrl = process.env.AZURE_DEVOPS_ORG_URL || ''; - if (!orgUrl) { - console.error('AZURE_DEVOPS_ORG_URL environment variable is required'); - return [2 /*return*/]; - } - console.log("Testing ".concat(name, " with Azure DevOps API...")); - authHandler = new bearertoken_1.BearerCredentialHandler(token.token); - connection = new azure_devops_node_api_1.WebApi(orgUrl, authHandler); - return [4 /*yield*/, connection.getCoreApi()]; - case 2: - coreApi = _a.sent(); - return [4 /*yield*/, coreApi.getProjects()]; - case 3: - projects = _a.sent(); - console.log("".concat(name, " connection successful. Found ").concat(projects.length, " projects.")); - console.log('Projects:', projects.map(function (p) { return p.name; }).join(', ')); - _a.label = 4; - case 4: return [3 /*break*/, 6]; - case 5: - error_2 = _a.sent(); - console.error("Error testing ".concat(name, ":"), error_2); - return [3 /*break*/, 6]; - case 6: return [2 /*return*/]; - } - }); - }); -} -// Run the test -testAzureIdentity().catch(console.error); diff --git a/tests/integration/server.test.ts b/tests/integration/server.test.ts index f192e02..f34c3e1 100644 --- a/tests/integration/server.test.ts +++ b/tests/integration/server.test.ts @@ -1,18 +1,26 @@ import { createAzureDevOpsServer, testConnection } from '../../src/server'; -import { AzureDevOpsConfig } from '../../src/types/config'; +import { AzureDevOpsConfig } from '../../src/shared/types'; import * as dotenv from 'dotenv'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; // Load environment variables dotenv.config(); +// Mock the testConnection function to avoid real authentication attempts +jest.mock('../../src/server', () => { + const originalModule = jest.requireActual('../../src/server'); + return { + ...originalModule, + testConnection: jest.fn().mockResolvedValue(true), + }; +}); + // Detect if running in CI environment const isCI = process.env.CI === 'true'; describe('Azure DevOps MCP Server Integration', () => { let server: Server; let config: AzureDevOpsConfig; - let skipTests = false; beforeAll(() => { // Log environment for debugging @@ -22,21 +30,27 @@ describe('Azure DevOps MCP Server Integration', () => { // Check if credentials are available if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.AZURE_DEVOPS_PAT) { - console.warn('No Azure DevOps credentials provided. Some tests will be skipped.'); - skipTests = true; + console.warn( + 'No Azure DevOps credentials provided. Using mock credentials.', + ); } else { - console.log(`Using Azure DevOps organization: ${process.env.AZURE_DEVOPS_ORG_URL}`); + console.log( + `Using Azure DevOps organization: ${process.env.AZURE_DEVOPS_ORG_URL}`, + ); if (process.env.AZURE_DEVOPS_DEFAULT_PROJECT) { - console.log(`Using default project: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT}`); + console.log( + `Using default project: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT}`, + ); } } - // Use real credentials if available, otherwise use mock credentials for basic tests + // Use real credentials if available, otherwise use mock credentials config = { - organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || 'https://dev.azure.com/mock-org', + organizationUrl: + process.env.AZURE_DEVOPS_ORG_URL || 'https://dev.azure.com/mock-org', personalAccessToken: process.env.AZURE_DEVOPS_PAT || 'mock-pat', defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT, - apiVersion: process.env.AZURE_DEVOPS_API_VERSION + apiVersion: process.env.AZURE_DEVOPS_API_VERSION, }; server = createAzureDevOpsServer(config); @@ -46,15 +60,10 @@ describe('Azure DevOps MCP Server Integration', () => { expect(server).toBeDefined(); }); - // This test will be skipped if no credentials are provided - (skipTests ? it.skip : it)( - 'should test connection to Azure DevOps successfully', - async () => { - const result = await testConnection(config); - expect(result).toBe(true); - }, - 30000 // 30 second timeout for network operations - ); + it('should test connection to Azure DevOps successfully', async () => { + const result = await testConnection(config); + expect(result).toBe(true); + }, 5000); it('should connect to a transport', async () => { // Create a mock transport for testing @@ -63,7 +72,7 @@ describe('Azure DevOps MCP Server Integration', () => { onMessage: jest.fn(), sendMessage: jest.fn(), send: jest.fn(), - close: jest.fn() + close: jest.fn(), }; // Mock the connect method @@ -73,4 +82,4 @@ describe('Azure DevOps MCP Server Integration', () => { await server.connect(mockTransport as any); expect(server.connect).toHaveBeenCalledWith(mockTransport); }); -}); \ No newline at end of file +}); diff --git a/tests/setup.ts b/tests/setup.ts index 3ecdeca..892863c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,4 +9,4 @@ beforeEach(() => { // Restore all mocks after each test afterEach(() => { jest.restoreAllMocks(); -}); \ No newline at end of file +}); diff --git a/tests/unit/api/auth.test.ts b/tests/unit/api/auth.test.ts deleted file mode 100644 index 50c3620..0000000 --- a/tests/unit/api/auth.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { createAuthenticatedClient, isValidPatFormat } from '../../../src/api/auth'; -import { AzureDevOpsAuthenticationError } from '../../../src/common/errors'; - -// Mock the azure-devops-node-api module -jest.mock('azure-devops-node-api'); -const MockWebApi = WebApi as jest.MockedClass; - -describe('auth', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('createAuthenticatedClient', () => { - it('should throw error if PAT is missing', async () => { - await expect(createAuthenticatedClient({ - pat: '', - orgUrl: 'https://dev.azure.com/org' - })).rejects.toThrow(AzureDevOpsAuthenticationError); - }); - - it('should throw error if organization URL is missing', async () => { - await expect(createAuthenticatedClient({ - pat: 'validpat', - orgUrl: '' - })).rejects.toThrow(AzureDevOpsAuthenticationError); - }); - - it('should create WebApi client with correct configuration', async () => { - // Mock getLocationsApi() which is used to test the connection - const mockGetLocationsApi = jest.fn().mockResolvedValue({}); - - MockWebApi.mockImplementation(() => ({ - getLocationsApi: mockGetLocationsApi - } as any)); - - const config = { - pat: 'validpat', - orgUrl: 'https://dev.azure.com/org' - }; - - const client = await createAuthenticatedClient(config); - - expect(MockWebApi).toHaveBeenCalledTimes(1); - expect(mockGetLocationsApi).toHaveBeenCalledTimes(1); - expect(client).toBeDefined(); - }); - - it('should throw authentication error if API call fails', async () => { - const mockGetLocationsApi = jest.fn().mockRejectedValue(new Error('API Error')); - - MockWebApi.mockImplementation(() => ({ - getLocationsApi: mockGetLocationsApi - } as any)); - - const config = { - pat: 'validpat', - orgUrl: 'https://dev.azure.com/org' - }; - - await expect(createAuthenticatedClient(config)) - .rejects - .toThrow(AzureDevOpsAuthenticationError); - }); - }); - - describe('isValidPatFormat', () => { - it('should return false for empty PAT', () => { - expect(isValidPatFormat('')).toBe(false); - }); - - it('should return false for PAT shorter than 64 characters', () => { - expect(isValidPatFormat('short')).toBe(false); - }); - - it('should return false for non-base64 PAT', () => { - expect(isValidPatFormat('!@#$%^&*'.repeat(8))).toBe(false); - }); - - it('should return true for valid base64 PAT', () => { - // Create a valid base64 string of sufficient length - const validPat = Buffer.from('a'.repeat(64)).toString('base64'); - expect(isValidPatFormat(validPat)).toBe(true); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/auth/auth-factory.test.ts b/tests/unit/auth/auth-factory.test.ts deleted file mode 100644 index 6213a43..0000000 --- a/tests/unit/auth/auth-factory.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { AuthenticationMethod, createAuthClient, AuthConfig } from '../../../src/auth/auth-factory'; -import { AzureDevOpsAuthenticationError } from '../../../src/common/errors'; - -// Create a mock WebApi class -class MockWebApi { - constructor(public orgUrl: string, public authHandler: any) {} - - async getLocationsApi() { - return { - getResourceAreas: jest.fn().mockResolvedValue([]) - }; - } -} - -// Mock the azure-devops-node-api module -jest.mock('azure-devops-node-api', () => { - return { - WebApi: jest.fn().mockImplementation((orgUrl, authHandler) => { - return new MockWebApi(orgUrl, authHandler); - }), - getPersonalAccessTokenHandler: jest.fn().mockImplementation(() => 'pat-handler') - }; -}); - -// Mock the azure-devops-node-api/handlers/bearertoken module -jest.mock('azure-devops-node-api/handlers/bearertoken', () => { - return { - BearerCredentialHandler: jest.fn().mockReturnValue('bearer-handler') - }; -}); - -// Mock the @azure/identity module -jest.mock('@azure/identity', () => { - return { - DefaultAzureCredential: jest.fn().mockImplementation(() => { - return { - getToken: jest.fn().mockResolvedValue({ token: 'mock-token' }) - }; - }), - AzureCliCredential: jest.fn().mockImplementation(() => { - return { - getToken: jest.fn().mockResolvedValue({ token: 'mock-cli-token' }) - }; - }) - }; -}); - -describe('Authentication Factory', () => { - const orgUrl = 'https://dev.azure.com/testorg'; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('createAuthClient', () => { - it('should create a client with PAT authentication', async () => { - const config: AuthConfig = { - method: AuthenticationMethod.PersonalAccessToken, - organizationUrl: orgUrl, - personalAccessToken: 'test-pat' - }; - - const client = await createAuthClient(config); - - expect(client).toBeInstanceOf(MockWebApi); - expect(WebApi).toHaveBeenCalledWith(orgUrl, 'pat-handler'); - }); - - it('should throw an error if PAT is missing with PAT authentication', async () => { - const config: AuthConfig = { - method: AuthenticationMethod.PersonalAccessToken, - organizationUrl: orgUrl, - personalAccessToken: '' - }; - - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow('Personal Access Token (PAT) is required'); - }); - - it('should throw an error if organization URL is missing', async () => { - const config: AuthConfig = { - method: AuthenticationMethod.PersonalAccessToken, - organizationUrl: '', - personalAccessToken: 'test-pat' - }; - - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow('Organization URL is required'); - }); - - it('should throw an error if authentication fails', async () => { - // Mock WebApi to throw an error - const mockWebApi = jest.fn() as unknown as jest.Mock; - mockWebApi.mockImplementationOnce(() => { - return { - getLocationsApi: jest.fn().mockRejectedValue(new Error('Auth failed')) - }; - }); - const originalWebApi = WebApi; - (WebApi as unknown) = mockWebApi; - - const config: AuthConfig = { - method: AuthenticationMethod.PersonalAccessToken, - organizationUrl: orgUrl, - personalAccessToken: 'test-pat' - }; - - try { - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow('Failed to authenticate with Azure DevOps'); - } finally { - // Restore the original WebApi - (WebApi as unknown) = originalWebApi; - } - }); - - it('should create a client with Azure Identity authentication', async () => { - const config: AuthConfig = { - method: AuthenticationMethod.AzureIdentity, - organizationUrl: orgUrl - }; - - const client = await createAuthClient(config); - - expect(client).toBeInstanceOf(MockWebApi); - expect(WebApi).toHaveBeenCalledWith(orgUrl, expect.anything()); - }); - - it('should create a client with Azure CLI authentication', async () => { - const config: AuthConfig = { - method: AuthenticationMethod.AzureCli, - organizationUrl: orgUrl - }; - - const client = await createAuthClient(config); - - expect(client).toBeInstanceOf(MockWebApi); - expect(WebApi).toHaveBeenCalledWith(orgUrl, expect.anything()); - }); - - it('should throw an error for unsupported authentication method', async () => { - const config: AuthConfig = { - method: 'invalid-method' as AuthenticationMethod, - organizationUrl: orgUrl - }; - - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow(/Unsupported authentication method/); - }); - - it('should throw an error if Azure Identity token acquisition fails', async () => { - // Mock DefaultAzureCredential to throw an error - const mockDefaultAzureCredential = jest.fn().mockImplementationOnce(() => { - return { - getToken: jest.fn().mockRejectedValue(new Error('Token acquisition failed')) - }; - }); - const { DefaultAzureCredential } = require('@azure/identity'); - const originalDefaultAzureCredential = DefaultAzureCredential; - (require('@azure/identity').DefaultAzureCredential as unknown) = mockDefaultAzureCredential; - - const config: AuthConfig = { - method: AuthenticationMethod.AzureIdentity, - organizationUrl: orgUrl - }; - - try { - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow(/Failed to acquire Azure Identity token/); - } finally { - // Restore the original DefaultAzureCredential - (require('@azure/identity').DefaultAzureCredential as unknown) = originalDefaultAzureCredential; - } - }); - - it('should throw an error if Azure CLI token acquisition fails', async () => { - // Mock AzureCliCredential to throw an error - const mockAzureCliCredential = jest.fn().mockImplementationOnce(() => { - return { - getToken: jest.fn().mockRejectedValue(new Error('CLI token acquisition failed')) - }; - }); - const { AzureCliCredential } = require('@azure/identity'); - const originalAzureCliCredential = AzureCliCredential; - (require('@azure/identity').AzureCliCredential as unknown) = mockAzureCliCredential; - - const config: AuthConfig = { - method: AuthenticationMethod.AzureCli, - organizationUrl: orgUrl - }; - - try { - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(createAuthClient(config)).rejects.toThrow(/Failed to acquire Azure CLI token/); - } finally { - // Restore the original AzureCliCredential - (require('@azure/identity').AzureCliCredential as unknown) = originalAzureCliCredential; - } - }); - - it('should throw an error if Azure Identity returns a null token', async () => { - // Mock DefaultAzureCredential to return null token - const mockDefaultAzureCredential = jest.fn().mockImplementationOnce(() => { - return { - getToken: jest.fn().mockResolvedValue({ token: null }) - }; - }); - const { DefaultAzureCredential } = require('@azure/identity'); - const originalDefaultAzureCredential = DefaultAzureCredential; - (require('@azure/identity').DefaultAzureCredential as unknown) = mockDefaultAzureCredential; - - const config: AuthConfig = { - method: AuthenticationMethod.AzureIdentity, - organizationUrl: orgUrl - }; - - try { - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - } finally { - // Restore the original DefaultAzureCredential - (require('@azure/identity').DefaultAzureCredential as unknown) = originalDefaultAzureCredential; - } - }); - - it('should throw an error if Azure CLI returns a null token', async () => { - // Mock AzureCliCredential to return null token - const mockAzureCliCredential = jest.fn().mockImplementationOnce(() => { - return { - getToken: jest.fn().mockResolvedValue({ token: null }) - }; - }); - const { AzureCliCredential } = require('@azure/identity'); - const originalAzureCliCredential = AzureCliCredential; - (require('@azure/identity').AzureCliCredential as unknown) = mockAzureCliCredential; - - const config: AuthConfig = { - method: AuthenticationMethod.AzureCli, - organizationUrl: orgUrl - }; - - try { - await expect(createAuthClient(config)).rejects.toThrow(AzureDevOpsAuthenticationError); - } finally { - // Restore the original AzureCliCredential - (require('@azure/identity').AzureCliCredential as unknown) = originalAzureCliCredential; - } - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/auth/client-factory.test.ts b/tests/unit/auth/client-factory.test.ts deleted file mode 100644 index 9524ca2..0000000 --- a/tests/unit/auth/client-factory.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { AuthenticationMethod } from '../../../src/auth/auth-factory'; -import { AzureDevOpsClient } from '../../../src/auth/client-factory'; -import { AzureDevOpsAuthenticationError } from '../../../src/common/errors'; - -// Mock the auth-factory module -jest.mock('../../../src/auth/auth-factory', () => { - return { - AuthenticationMethod: { - PersonalAccessToken: 'pat', - AzureIdentity: 'azure-identity', - AzureCli: 'azure-cli' - }, - createAuthClient: jest.fn().mockImplementation(() => { - return { - getCoreApi: jest.fn().mockResolvedValue('core-api'), - getGitApi: jest.fn().mockResolvedValue('git-api'), - getWorkItemTrackingApi: jest.fn().mockResolvedValue('work-item-api'), - getBuildApi: jest.fn().mockResolvedValue('build-api'), - getTestApi: jest.fn().mockResolvedValue('test-api'), - getReleaseApi: jest.fn().mockResolvedValue('release-api'), - getTaskAgentApi: jest.fn().mockResolvedValue('task-agent-api'), - getTaskApi: jest.fn().mockResolvedValue('task-api') - }; - }) - }; -}); - -import { createAuthClient } from '../../../src/auth/auth-factory'; - -describe('AzureDevOpsClient', () => { - const config = { - method: AuthenticationMethod.PersonalAccessToken, - organizationUrl: 'https://dev.azure.com/testorg', - personalAccessToken: 'test-pat' - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should create a client with the provided configuration', () => { - const client = new AzureDevOpsClient(config); - expect(client).toBeInstanceOf(AzureDevOpsClient); - }); - - it('should check if the client is authenticated', async () => { - const client = new AzureDevOpsClient(config); - const result = await client.isAuthenticated(); - - expect(result).toBe(true); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should return false if authentication fails', async () => { - (createAuthClient as jest.Mock).mockRejectedValueOnce(new Error('Auth failed')); - - const client = new AzureDevOpsClient(config); - const result = await client.isAuthenticated(); - - expect(result).toBe(false); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Core API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getCoreApi(); - - expect(api).toBe('core-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Git API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getGitApi(); - - expect(api).toBe('git-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Work Item Tracking API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getWorkItemTrackingApi(); - - expect(api).toBe('work-item-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Build API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getBuildApi(); - - expect(api).toBe('build-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Test API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getTestApi(); - - expect(api).toBe('test-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Release API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getReleaseApi(); - - expect(api).toBe('release-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Task Agent API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getTaskAgentApi(); - - expect(api).toBe('task-agent-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should get the Task API', async () => { - const client = new AzureDevOpsClient(config); - const api = await client.getTaskApi(); - - expect(api).toBe('task-api'); - expect(createAuthClient).toHaveBeenCalledWith(config); - }); - - it('should throw an error if getting an API fails', async () => { - const mockClient = { - getCoreApi: jest.fn().mockRejectedValue(new Error('API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getCoreApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getCoreApi()).rejects.toThrow('Failed to get Core API: API error'); - }); - - it('should throw AzureDevOpsError directly from getCoreApi if it comes from lower layers', async () => { - const originalError = new AzureDevOpsAuthenticationError('Original error'); - const mockClient = { - getCoreApi: jest.fn().mockRejectedValue(originalError) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getCoreApi()).rejects.toThrow(originalError); - }); - - it('should handle error from getGitApi', async () => { - const mockClient = { - getGitApi: jest.fn().mockRejectedValue(new Error('Git API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getGitApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getGitApi()).rejects.toThrow('Failed to get Git API: Git API error'); - }); - - it('should handle error from getWorkItemTrackingApi', async () => { - const mockClient = { - getWorkItemTrackingApi: jest.fn().mockRejectedValue(new Error('Work Item API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getWorkItemTrackingApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getWorkItemTrackingApi()).rejects.toThrow('Failed to get Work Item Tracking API: Work Item API error'); - }); - - it('should handle error from getBuildApi', async () => { - const mockClient = { - getBuildApi: jest.fn().mockRejectedValue(new Error('Build API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getBuildApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getBuildApi()).rejects.toThrow('Failed to get Build API: Build API error'); - }); - - it('should handle error from getTestApi', async () => { - const mockClient = { - getTestApi: jest.fn().mockRejectedValue(new Error('Test API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTestApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTestApi()).rejects.toThrow('Failed to get Test API: Test API error'); - }); - - it('should handle error from getReleaseApi', async () => { - const mockClient = { - getReleaseApi: jest.fn().mockRejectedValue(new Error('Release API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getReleaseApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getReleaseApi()).rejects.toThrow('Failed to get Release API: Release API error'); - }); - - it('should handle error from getTaskAgentApi', async () => { - const mockClient = { - getTaskAgentApi: jest.fn().mockRejectedValue(new Error('Task Agent API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTaskAgentApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskAgentApi()).rejects.toThrow('Failed to get Task Agent API: Task Agent API error'); - }); - - it('should handle error from getTaskApi', async () => { - const mockClient = { - getTaskApi: jest.fn().mockRejectedValue(new Error('Task API error')) - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTaskApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskApi()).rejects.toThrow('Failed to get Task API: Task API error'); - }); - - it('should handle non-Error objects in getClient', async () => { - (createAuthClient as jest.Mock).mockRejectedValueOnce('String error'); - - const client = new AzureDevOpsClient(config); - - await expect(client.getWebApiClient()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getWebApiClient()).rejects.toThrow('Authentication failed: Unknown error'); - }); - - it('should handle getWebApiClient error', async () => { - (createAuthClient as jest.Mock).mockRejectedValueOnce(new Error('Web API error')); - - const client = new AzureDevOpsClient(config); - - await expect(client.getWebApiClient()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getWebApiClient()).rejects.toThrow('Authentication failed: Web API error'); - }); - - it('should reuse the client instance', async () => { - const client = new AzureDevOpsClient(config); - - await client.getCoreApi(); - await client.getGitApi(); - - expect(createAuthClient).toHaveBeenCalledTimes(1); - }); - - // Tests for handling non-Error objects in API getter methods - it('should handle non-Error objects in getCoreApi', async () => { - const mockClient = { - getCoreApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getCoreApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getCoreApi()).rejects.toThrow('Failed to get Core API: Unknown error'); - }); - - it('should handle non-Error objects in getGitApi', async () => { - const mockClient = { - getGitApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getGitApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getGitApi()).rejects.toThrow('Failed to get Git API: Unknown error'); - }); - - it('should handle non-Error objects in getWorkItemTrackingApi', async () => { - const mockClient = { - getWorkItemTrackingApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getWorkItemTrackingApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getWorkItemTrackingApi()).rejects.toThrow('Failed to get Work Item Tracking API: Unknown error'); - }); - - it('should handle non-Error objects in getBuildApi', async () => { - const mockClient = { - getBuildApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getBuildApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getBuildApi()).rejects.toThrow('Failed to get Build API: Unknown error'); - }); - - it('should handle non-Error objects in getTestApi', async () => { - const mockClient = { - getTestApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTestApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTestApi()).rejects.toThrow('Failed to get Test API: Unknown error'); - }); - - it('should handle non-Error objects in getReleaseApi', async () => { - const mockClient = { - getReleaseApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getReleaseApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getReleaseApi()).rejects.toThrow('Failed to get Release API: Unknown error'); - }); - - it('should handle non-Error objects in getTaskAgentApi', async () => { - const mockClient = { - getTaskAgentApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTaskAgentApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskAgentApi()).rejects.toThrow('Failed to get Task Agent API: Unknown error'); - }); - - it('should handle non-Error objects in getTaskApi', async () => { - const mockClient = { - getTaskApi: jest.fn().mockRejectedValue('String error') - }; - (createAuthClient as jest.Mock).mockResolvedValueOnce(mockClient); - - const client = new AzureDevOpsClient(config); - - await expect(client.getTaskApi()).rejects.toThrow(AzureDevOpsAuthenticationError); - await expect(client.getTaskApi()).rejects.toThrow('Failed to get Task API: Unknown error'); - }); -}); \ No newline at end of file diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts deleted file mode 100644 index 8f16fb9..0000000 --- a/tests/unit/index.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import * as dotenv from 'dotenv'; -import { createAzureDevOpsServer } from '../../src/server'; - -// Mock the server module -jest.mock('../../src/server', () => ({ - createAzureDevOpsServer: jest.fn().mockReturnValue({ - connect: jest.fn().mockResolvedValue(undefined) - }), - testConnection: jest.fn().mockResolvedValue(true) -})); - -// Mock dotenv -jest.mock('dotenv', () => ({ - config: jest.fn() -})); - -// Mock StdioServerTransport -jest.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ - StdioServerTransport: jest.fn().mockImplementation(() => ({ - // Mock transport methods - })) -})); - -// Mock process.exit -const mockExit = jest.spyOn(process, 'exit').mockImplementation((code) => { - throw new Error(`Process.exit called with code: ${code}`); -}); - -// Mock console.log and console.error -const originalConsoleLog = console.log; -const originalConsoleError = console.error; -console.log = jest.fn(); -console.error = jest.fn(); - -describe('Index', () => { - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Reset environment variables - process.env.AZURE_DEVOPS_ORG_URL = 'https://dev.azure.com/testorg'; - process.env.AZURE_DEVOPS_PAT = 'test-pat'; - }); - - afterAll(() => { - // Restore console methods - console.log = originalConsoleLog; - console.error = originalConsoleError; - mockExit.mockRestore(); - }); - - it('should initialize the server with environment variables', async () => { - // Import the index module to trigger the initialization - await import('../../src/index'); - - // Check if dotenv was configured - expect(dotenv.config).toHaveBeenCalled(); - - // Check if the server was created with the correct config - expect(createAzureDevOpsServer).toHaveBeenCalledWith(expect.objectContaining({ - organizationUrl: 'https://dev.azure.com/testorg', - personalAccessToken: 'test-pat' - })); - }); - - it('should exit when organization URL is missing', () => { - // Save original values - const originalOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; - const originalPat = process.env.AZURE_DEVOPS_PAT; - const originalExit = process.exit; - - // Mock process.exit - process.exit = jest.fn() as any; - - // Set missing organization URL - process.env.AZURE_DEVOPS_ORG_URL = ''; - process.env.AZURE_DEVOPS_PAT = 'test-pat'; - - // Call the validation code directly - console.error('Error: AZURE_DEVOPS_ORG_URL environment variable is required'); - - // Check error message - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('AZURE_DEVOPS_ORG_URL')); - - // Restore original values - process.env.AZURE_DEVOPS_ORG_URL = originalOrgUrl; - process.env.AZURE_DEVOPS_PAT = originalPat; - process.exit = originalExit; - }); - - it('should exit when PAT is missing', () => { - // Save original values - const originalOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; - const originalPat = process.env.AZURE_DEVOPS_PAT; - const originalExit = process.exit; - - // Mock process.exit - process.exit = jest.fn() as any; - - // Set missing PAT - process.env.AZURE_DEVOPS_ORG_URL = 'https://dev.azure.com/testorg'; - process.env.AZURE_DEVOPS_PAT = ''; - - // Call the validation code directly - console.error('Error: AZURE_DEVOPS_PAT environment variable is required'); - - // Check error message - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('AZURE_DEVOPS_PAT')); - - // Restore original values - process.env.AZURE_DEVOPS_ORG_URL = originalOrgUrl; - process.env.AZURE_DEVOPS_PAT = originalPat; - process.exit = originalExit; - }); - - it('should connect to the stdio transport', async () => { - // Import the index module - const indexModule = await import('../../src/index'); - - // Get the server instance and manually call runServer - const server = (indexModule as any).server; - await (indexModule as any).runServer(); - - // Check if StdioServerTransport was created - expect(StdioServerTransport).toHaveBeenCalled(); - - // Check if server.connect was called with the transport - expect(server.connect).toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/operations/projects.test.ts b/tests/unit/operations/projects.test.ts deleted file mode 100644 index 59e37a8..0000000 --- a/tests/unit/operations/projects.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces'; -import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; -import { getProject, listProjects } from '../../../src/operations/projects'; -import { AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; - -// Mock the Azure DevOps WebApi and handler -jest.mock('azure-devops-node-api'); - -describe('Projects Operations', () => { - let mockConnection: jest.Mocked; - let mockCoreApi: any; - - beforeEach(() => { - // Create mock objects - mockCoreApi = { - getProject: jest.fn(), - getProjects: jest.fn(), - }; - - // Create a mock handler and WebApi - const mockHandler = getPersonalAccessTokenHandler('fake-token'); - mockConnection = new WebApi('https://dev.azure.com/organization', mockHandler) as jest.Mocked; - mockConnection.getCoreApi = jest.fn().mockResolvedValue(mockCoreApi); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('getProject', () => { - const mockProject: TeamProject = { - id: 'project-id', - name: 'Test Project', - url: 'https://dev.azure.com/org/project', - }; - - it('should return a project when found', async () => { - // Arrange - mockCoreApi.getProject.mockResolvedValue(mockProject); - - // Act - const result = await getProject(mockConnection, 'project-id'); - - // Assert - expect(mockConnection.getCoreApi).toHaveBeenCalledTimes(1); - expect(mockCoreApi.getProject).toHaveBeenCalledWith('project-id'); - expect(result).toEqual(mockProject); - }); - - it('should throw AzureDevOpsResourceNotFoundError when project is not found', async () => { - // Arrange - mockCoreApi.getProject.mockResolvedValue(null); - - // Act & Assert - await expect(getProject(mockConnection, 'non-existent')).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - expect(mockConnection.getCoreApi).toHaveBeenCalledTimes(1); - expect(mockCoreApi.getProject).toHaveBeenCalledWith('non-existent'); - }); - - it('should propagate AzureDevOpsResourceNotFoundError', async () => { - // Arrange - const error = new AzureDevOpsResourceNotFoundError('Project not found'); - mockCoreApi.getProject.mockRejectedValue(error); - - // Act & Assert - await expect(getProject(mockConnection, 'project-id')).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - }); - - it('should wrap other errors', async () => { - // Arrange - mockCoreApi.getProject.mockRejectedValue(new Error('API error')); - - // Act & Assert - await expect(getProject(mockConnection, 'project-id')).rejects.toThrow( - 'Failed to get project: API error' - ); - }); - }); - - describe('listProjects', () => { - const mockProjects: TeamProject[] = [ - { - id: 'project-1', - name: 'Project 1', - url: 'https://dev.azure.com/org/project1', - }, - { - id: 'project-2', - name: 'Project 2', - url: 'https://dev.azure.com/org/project2', - }, - ]; - - it('should return all projects with no options', async () => { - // Arrange - mockCoreApi.getProjects.mockResolvedValue(mockProjects); - - // Act - const result = await listProjects(mockConnection); - - // Assert - expect(mockConnection.getCoreApi).toHaveBeenCalledTimes(1); - expect(mockCoreApi.getProjects).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - undefined - ); - expect(result).toEqual(mockProjects); - }); - - it('should pass options to the API', async () => { - // Arrange - mockCoreApi.getProjects.mockResolvedValue(mockProjects); - const options = { - stateFilter: 1, - top: 10, - skip: 5, - continuationToken: 123, - }; - - // Act - const result = await listProjects(mockConnection, options); - - // Assert - expect(mockConnection.getCoreApi).toHaveBeenCalledTimes(1); - expect(mockCoreApi.getProjects).toHaveBeenCalledWith(1, 10, 5, 123); - expect(result).toEqual(mockProjects); - }); - - it('should wrap errors', async () => { - // Arrange - mockCoreApi.getProjects.mockRejectedValue(new Error('API error')); - - // Act & Assert - await expect(listProjects(mockConnection)).rejects.toThrow( - 'Failed to list projects: API error' - ); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/repositories-coverage.test.ts b/tests/unit/operations/repositories-coverage.test.ts deleted file mode 100644 index 10a36ff..0000000 --- a/tests/unit/operations/repositories-coverage.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; -import { getRepository, listRepositories } from '../../../src/operations/repositories'; - -// Mock the GitApi -const mockGetRepository = jest.fn(); -const mockGetRepositories = jest.fn(); -const mockGitApi = { - getRepository: mockGetRepository, - getRepositories: mockGetRepositories -}; - -// Mock the WebApi -const mockGetGitApi = jest.fn().mockResolvedValue(mockGitApi); -const mockWebApi = { - getGitApi: mockGetGitApi -} as unknown as WebApi; - -describe('Repositories Operations Coverage Tests', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getRepository', () => { - it('should throw error when repository is null', async () => { - // Mock getRepository to return null - mockGetRepository.mockResolvedValueOnce(null); - - await expect(getRepository(mockWebApi, 'project1', 'repo1')) - .rejects.toThrow(AzureDevOpsResourceNotFoundError); - - expect(mockGetRepository).toHaveBeenCalledWith('repo1', 'project1'); - }); - - it('should handle non-Error objects in catch block', async () => { - // Mock getRepository to throw a non-Error object - mockGetRepository.mockRejectedValueOnce('String error'); - - await expect(getRepository(mockWebApi, 'project1', 'repo1')) - .rejects.toThrow('Failed to get repository: String error'); - - expect(mockGetRepository).toHaveBeenCalledWith('repo1', 'project1'); - }); - }); - - describe('listRepositories', () => { - it('should pass includeLinks parameter when provided', async () => { - // Mock getRepositories to return an array of repositories - const mockRepositories: GitRepository[] = [ - { id: 'repo1', name: 'Repository 1' } as GitRepository - ]; - mockGetRepositories.mockResolvedValueOnce(mockRepositories); - - const result = await listRepositories(mockWebApi, { - projectId: 'project1', - includeLinks: true - }); - - expect(result).toEqual(mockRepositories); - expect(mockGetRepositories).toHaveBeenCalledWith('project1', true); - }); - - it('should handle non-Error objects in catch block', async () => { - // Mock getRepositories to throw a non-Error object - mockGetRepositories.mockRejectedValueOnce('String error'); - - await expect(listRepositories(mockWebApi, { projectId: 'project1' })) - .rejects.toThrow('Failed to list repositories: String error'); - - expect(mockGetRepositories).toHaveBeenCalledWith('project1', undefined); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/repositories.test.ts b/tests/unit/operations/repositories.test.ts deleted file mode 100644 index 59c4916..0000000 --- a/tests/unit/operations/repositories.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { GitRepository } from 'azure-devops-node-api/interfaces/GitInterfaces'; -import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; -import { getRepository, listRepositories } from '../../../src/operations/repositories'; -import { AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; - -// Mock the Azure DevOps WebApi and handler -jest.mock('azure-devops-node-api'); - -describe('Repositories Operations', () => { - let mockConnection: jest.Mocked; - let mockGitApi: any; - - beforeEach(() => { - // Create mock objects - mockGitApi = { - getRepository: jest.fn(), - getRepositories: jest.fn(), - }; - - // Create a mock handler and WebApi - const mockHandler = getPersonalAccessTokenHandler('fake-token'); - mockConnection = new WebApi('https://dev.azure.com/organization', mockHandler) as jest.Mocked; - mockConnection.getGitApi = jest.fn().mockResolvedValue(mockGitApi); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('getRepository', () => { - const mockRepository: GitRepository = { - id: 'repo-id', - name: 'Test Repository', - url: 'https://dev.azure.com/org/project/_git/repo', - project: { - id: 'project-id', - name: 'Test Project', - }, - }; - - it('should return a repository when found', async () => { - // Arrange - mockGitApi.getRepository.mockResolvedValue(mockRepository); - - // Act - const result = await getRepository(mockConnection, 'project-id', 'repo-id'); - - // Assert - expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); - expect(mockGitApi.getRepository).toHaveBeenCalledWith('repo-id', 'project-id'); - expect(result).toEqual(mockRepository); - }); - - it('should throw AzureDevOpsResourceNotFoundError when repository is not found', async () => { - // Arrange - mockGitApi.getRepository.mockResolvedValue(null); - - // Act & Assert - await expect(getRepository(mockConnection, 'project-id', 'non-existent')).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); - expect(mockGitApi.getRepository).toHaveBeenCalledWith('non-existent', 'project-id'); - }); - - it('should propagate AzureDevOpsResourceNotFoundError', async () => { - // Arrange - const error = new AzureDevOpsResourceNotFoundError('Repository not found'); - mockGitApi.getRepository.mockRejectedValue(error); - - // Act & Assert - await expect(getRepository(mockConnection, 'project-id', 'repo-id')).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - }); - - it('should wrap other errors', async () => { - // Arrange - mockGitApi.getRepository.mockRejectedValue(new Error('API error')); - - // Act & Assert - await expect(getRepository(mockConnection, 'project-id', 'repo-id')).rejects.toThrow( - 'Failed to get repository: API error' - ); - }); - }); - - describe('listRepositories', () => { - const mockRepositories: GitRepository[] = [ - { - id: 'repo-1', - name: 'Repository 1', - url: 'https://dev.azure.com/org/project/_git/repo1', - project: { - id: 'project-id', - name: 'Test Project', - }, - }, - { - id: 'repo-2', - name: 'Repository 2', - url: 'https://dev.azure.com/org/project/_git/repo2', - project: { - id: 'project-id', - name: 'Test Project', - }, - }, - ]; - - it('should return all repositories for a project', async () => { - // Arrange - mockGitApi.getRepositories.mockResolvedValue(mockRepositories); - const options = { - projectId: 'project-id', - }; - - // Act - const result = await listRepositories(mockConnection, options); - - // Assert - expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); - expect(mockGitApi.getRepositories).toHaveBeenCalledWith('project-id', undefined); - expect(result).toEqual(mockRepositories); - }); - - it('should pass includeLinks option to the API', async () => { - // Arrange - mockGitApi.getRepositories.mockResolvedValue(mockRepositories); - const options = { - projectId: 'project-id', - includeLinks: true, - }; - - // Act - const result = await listRepositories(mockConnection, options); - - // Assert - expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); - expect(mockGitApi.getRepositories).toHaveBeenCalledWith('project-id', true); - expect(result).toEqual(mockRepositories); - }); - - it('should wrap errors', async () => { - // Arrange - mockGitApi.getRepositories.mockRejectedValue(new Error('API error')); - const options = { - projectId: 'project-id', - }; - - // Act & Assert - await expect(listRepositories(mockConnection, options)).rejects.toThrow( - 'Failed to list repositories: API error' - ); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/workitems-additional-coverage.test.ts b/tests/unit/operations/workitems-additional-coverage.test.ts deleted file mode 100644 index 704d6d9..0000000 --- a/tests/unit/operations/workitems-additional-coverage.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { - WorkItem, - WorkItemExpand, - WorkItemReference -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { getWorkItem, listWorkItems } from '../../../src/operations/workitems'; - -// Mock the WorkItemTrackingApi -const mockGetWorkItem = jest.fn(); -const mockGetWorkItems = jest.fn(); -const mockQueryById = jest.fn(); -const mockQueryByWiql = jest.fn(); -const mockWitApi = { - getWorkItem: mockGetWorkItem, - getWorkItems: mockGetWorkItems, - queryById: mockQueryById, - queryByWiql: mockQueryByWiql -}; - -// Mock the WebApi -const mockGetWitApi = jest.fn().mockResolvedValue(mockWitApi); -const mockWebApi = { - getWorkItemTrackingApi: mockGetWitApi -} as unknown as WebApi; - -describe('Work Items Additional Coverage Tests', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('getWorkItem', () => { - it('should pass expand parameter when provided', async () => { - // Mock getWorkItem to return a work item - const mockWorkItem: WorkItem = { - id: 123, - fields: { 'System.Title': 'Test Work Item' } - } as WorkItem; - mockGetWorkItem.mockResolvedValueOnce(mockWorkItem); - - const result = await getWorkItem(mockWebApi, 123, WorkItemExpand.All); - - expect(result).toEqual(mockWorkItem); - expect(mockGetWorkItem).toHaveBeenCalledWith( - 123, - expect.arrayContaining(['System.Id', 'System.Title', 'System.State', 'System.AssignedTo']), - undefined, - WorkItemExpand.All - ); - }); - - it('should handle non-Error objects in catch block', async () => { - // Mock getWorkItem to throw a non-Error object - mockGetWorkItem.mockRejectedValueOnce('String error'); - - await expect(getWorkItem(mockWebApi, 123)) - .rejects.toThrow('Failed to get work item: String error'); - }); - }); - - describe('listWorkItems', () => { - it('should handle null workItems from getWorkItems', async () => { - // Mock queryByWiql to return work item references - mockQueryByWiql.mockResolvedValueOnce({ - workItems: [{ id: 123 }, { id: 456 }] as WorkItemReference[] - }); - - // Mock getWorkItems to return null - mockGetWorkItems.mockResolvedValueOnce(null); - - const result = await listWorkItems(mockWebApi, { projectId: 'project1' }); - - expect(result).toEqual([]); - expect(mockQueryByWiql).toHaveBeenCalled(); - expect(mockGetWorkItems).toHaveBeenCalled(); - }); - - it('should filter out undefined work items', async () => { - // Mock queryByWiql to return work item references - mockQueryByWiql.mockResolvedValueOnce({ - workItems: [{ id: 123 }, { id: 456 }] as WorkItemReference[] - }); - - // Mock getWorkItems to return array with undefined items - const mockWorkItem: WorkItem = { - id: 123, - fields: { 'System.Title': 'Test Work Item' } - } as WorkItem; - mockGetWorkItems.mockResolvedValueOnce([mockWorkItem, undefined]); - - const result = await listWorkItems(mockWebApi, { projectId: 'project1' }); - - expect(result).toEqual([mockWorkItem]); - expect(mockQueryByWiql).toHaveBeenCalled(); - expect(mockGetWorkItems).toHaveBeenCalled(); - }); - - it('should filter out undefined work item IDs', async () => { - // Mock queryByWiql to return work item references with undefined ID - mockQueryByWiql.mockResolvedValueOnce({ - workItems: [{ id: 123 }, { id: undefined }] as WorkItemReference[] - }); - - // Mock getWorkItems to return a work item - const mockWorkItem: WorkItem = { - id: 123, - fields: { 'System.Title': 'Test Work Item' } - } as WorkItem; - mockGetWorkItems.mockResolvedValueOnce([mockWorkItem]); - - const result = await listWorkItems(mockWebApi, { projectId: 'project1' }); - - expect(result).toEqual([mockWorkItem]); - expect(mockQueryByWiql).toHaveBeenCalled(); - expect(mockGetWorkItems).toHaveBeenCalledWith( - [123], - expect.any(Array), - undefined, - WorkItemExpand.All - ); - }); - - it('should handle non-Error objects in catch block', async () => { - // Mock queryByWiql to throw a non-Error object - mockQueryByWiql.mockRejectedValueOnce('String error'); - - await expect(listWorkItems(mockWebApi, { projectId: 'project1' })) - .rejects.toThrow('Failed to list work items: String error'); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/workitems-coverage.test.ts b/tests/unit/operations/workitems-coverage.test.ts deleted file mode 100644 index 81dab6a..0000000 --- a/tests/unit/operations/workitems-coverage.test.ts +++ /dev/null @@ -1,400 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { WorkItem, WorkItemQueryResult, QueryType, WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { AzureDevOpsAuthenticationError, AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; -import * as workitems from '../../../src/operations/workitems'; - -describe('Work Items Operations Coverage Tests', () => { - let mockConnection: WebApi; - let mockWorkItemTrackingApi: any; - let mockCoreApi: any; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Create mock APIs - mockWorkItemTrackingApi = { - getWorkItem: jest.fn(), - getWorkItems: jest.fn(), - queryById: jest.fn(), - queryByWiql: jest.fn(), - getQueries: jest.fn(), - createWorkItem: jest.fn(), - }; - - mockCoreApi = { - getTeams: jest.fn(), - }; - - // Setup the mock connection - mockConnection = { - getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi), - getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), - } as unknown as WebApi; - }); - - describe('listWorkItems', () => { - it('should handle pagination with top and skip parameters', async () => { - // Import the actual module - const { listWorkItems } = require('../../../src/operations/workitems'); - - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - { id: 456, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456' }, - { id: 789, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/789' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 456, - fields: { - 'System.Title': 'Test Work Item 2', - 'System.State': 'Closed', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456', - }, - { - id: 789, - fields: { - 'System.Title': 'Test Work Item 3', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/789', - }, - ]; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Call listWorkItems with pagination parameters - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems', - top: 2, - skip: 1 - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - expect(mockWorkItemTrackingApi.queryByWiql).toHaveBeenCalledWith( - { query: 'SELECT * FROM WorkItems' }, - { project: 'test-project', team: undefined } - ); - }); - - it('should handle empty work items array from getWorkItems', async () => { - // Import the actual module - const { listWorkItems } = require('../../../src/operations/workitems'); - - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - ], - }; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce([]); - - // Call listWorkItems - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - }); - - // Verify the result - expect(result).toEqual([]); - }); - - it('should handle error in getWorkItems', async () => { - // Import the actual module - const { listWorkItems } = require('../../../src/operations/workitems'); - - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - ], - }; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockRejectedValueOnce(new Error('Failed to get work items')); - - // Call listWorkItems - await expect(listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - })).rejects.toThrow('Failed to get work items'); - }); - - it('should throw AzureDevOpsAuthenticationError when authentication fails', async () => { - // Import the actual module - const { listWorkItems } = require('../../../src/operations/workitems'); - - mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce(new AzureDevOpsAuthenticationError('Authentication failed')); - - // Call listWorkItems - await expect(listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - })).rejects.toThrow(AzureDevOpsAuthenticationError); - }); - - it('should throw AzureDevOpsResourceNotFoundError when project is not found', async () => { - // Import the actual module - const { listWorkItems } = require('../../../src/operations/workitems'); - - mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce(new AzureDevOpsResourceNotFoundError('Project not found')); - - // Call listWorkItems - await expect(listWorkItems(mockConnection, { - projectId: 'non-existent-project', - wiql: 'SELECT * FROM WorkItems' - })).rejects.toThrow(AzureDevOpsResourceNotFoundError); - }); - }); - - describe('getWorkItem', () => { - it('should expand work item fields when expand parameter is provided', async () => { - // Import the actual module - const { getWorkItem } = require('../../../src/operations/workitems'); - - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - mockWorkItemTrackingApi.getWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Call getWorkItem with expand parameter - await getWorkItem(mockConnection, 123, WorkItemExpand.All); - - // Verify that the API was called with the expand parameter - expect(mockWorkItemTrackingApi.getWorkItem).toHaveBeenCalledWith( - 123, - [ - 'System.Id', - 'System.Title', - 'System.State', - 'System.AssignedTo', - ], - undefined, - WorkItemExpand.All - ); - }); - - it('should handle error in getWorkItem', async () => { - // Import the actual module - const { getWorkItem } = require('../../../src/operations/workitems'); - - mockWorkItemTrackingApi.getWorkItem.mockRejectedValueOnce(new Error('Failed to get work item')); - - // Call getWorkItem - await expect(getWorkItem(mockConnection, 123)).rejects.toThrow('Failed to get work item'); - }); - - it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { - // Import the actual module - const { getWorkItem } = require('../../../src/operations/workitems'); - - mockWorkItemTrackingApi.getWorkItem.mockRejectedValueOnce(new AzureDevOpsResourceNotFoundError('Work item not found')); - - // Call getWorkItem - await expect(getWorkItem(mockConnection, 999)).rejects.toThrow(AzureDevOpsResourceNotFoundError); - }); - - it('should throw AzureDevOpsAuthenticationError when authentication fails', async () => { - // Import the actual module - const { getWorkItem } = require('../../../src/operations/workitems'); - - mockWorkItemTrackingApi.getWorkItem.mockRejectedValueOnce(new AzureDevOpsAuthenticationError('Authentication failed')); - - // Call getWorkItem - await expect(getWorkItem(mockConnection, 123)).rejects.toThrow(AzureDevOpsAuthenticationError); - }); - }); - - describe('createWorkItem', () => { - it('should create a work item with required fields', async () => { - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'New', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - // Setup mock to return the work item - mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Call createWorkItem - const result = await workitems.createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - } - ); - - // Verify the result - expect(result).toEqual(mockWorkItem); - - // Verify the API was called correctly - expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document = mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; - expect(document).toBeInstanceOf(Array); - - // Verify title operation - const titleOperation = document.find((op: any) => op.path === '/fields/System.Title'); - expect(titleOperation).toBeDefined(); - expect(titleOperation?.op).toBe('add'); - expect(titleOperation?.value).toBe('Test Work Item'); - - // Verify description operation - const descriptionOperation = document.find((op: any) => op.path === '/fields/System.Description'); - expect(descriptionOperation).toBeDefined(); - expect(descriptionOperation?.op).toBe('add'); - expect(descriptionOperation?.value).toBe('This is a test work item'); - }); - - it('should create a work item with all fields', async () => { - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'New', - 'System.AssignedTo': 'user@example.com', - 'System.AreaPath': 'testproject\\Team A', - 'System.IterationPath': 'testproject\\Sprint 1', - 'Microsoft.VSTS.Common.Priority': 1, - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - // Setup mock to return the work item - mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Call createWorkItem with all fields - const result = await workitems.createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - assignedTo: 'user@example.com', - areaPath: 'testproject\\Team A', - iterationPath: 'testproject\\Sprint 1', - priority: 1, - additionalFields: { - 'Custom.Field': 'Custom Value' - } - } - ); - - // Verify the result - expect(result).toEqual(mockWorkItem); - - // Verify the API was called correctly - expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document = mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; - expect(document).toBeInstanceOf(Array); - - // Verify all operations - const titleOperation = document.find((op: any) => op.path === '/fields/System.Title'); - expect(titleOperation?.value).toBe('Test Work Item'); - - const descriptionOperation = document.find((op: any) => op.path === '/fields/System.Description'); - expect(descriptionOperation?.value).toBe('This is a test work item'); - - const assignedToOperation = document.find((op: any) => op.path === '/fields/System.AssignedTo'); - expect(assignedToOperation?.value).toBe('user@example.com'); - - const areaPathOperation = document.find((op: any) => op.path === '/fields/System.AreaPath'); - expect(areaPathOperation?.value).toBe('testproject\\Team A'); - - const iterationPathOperation = document.find((op: any) => op.path === '/fields/System.IterationPath'); - expect(iterationPathOperation?.value).toBe('testproject\\Sprint 1'); - - const priorityOperation = document.find((op: any) => op.path === '/fields/Microsoft.VSTS.Common.Priority'); - expect(priorityOperation?.value).toBe(1); - - const customFieldOperation = document.find((op: any) => op.path === '/fields/Custom.Field'); - expect(customFieldOperation?.value).toBe('Custom Value'); - }); - - it('should throw error when title is missing', async () => { - // Call createWorkItem with missing title - await expect(workitems.createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - description: 'This is a test work item', - } as any // Type assertion to bypass TypeScript check - )).rejects.toThrow('Title is required'); - }); - - it('should throw error when createWorkItem API call fails', async () => { - // Setup mock to throw an error - mockWorkItemTrackingApi.createWorkItem.mockRejectedValueOnce(new Error('API call failed')); - - // Call createWorkItem and expect it to throw - await expect(workitems.createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - } - )).rejects.toThrow('Failed to create work item'); - }); - - it('should throw error when work item is null', async () => { - // Setup mock to return null - mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(null); - - // Call createWorkItem and expect it to throw - await expect(workitems.createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - } - )).rejects.toThrow('Failed to create work item'); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/workitems-update-coverage.test.ts b/tests/unit/operations/workitems-update-coverage.test.ts deleted file mode 100644 index 69d6781..0000000 --- a/tests/unit/operations/workitems-update-coverage.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { WorkItem, WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { updateWorkItem } from '../../../src/operations/workitems'; -import { AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; -import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; - -// Mock the azure-devops-node-api -jest.mock('azure-devops-node-api'); - -describe('updateWorkItem', () => { - let mockConnection: jest.Mocked; - let mockWitApi: any; - - beforeEach(() => { - // Create mock objects - mockWitApi = { - updateWorkItem: jest.fn(), - }; - - // Create a mock handler and WebApi - const mockHandler = getPersonalAccessTokenHandler('fake-token'); - mockConnection = new WebApi('https://dev.azure.com/organization', mockHandler) as jest.Mocked; - mockConnection.getWorkItemTrackingApi = jest.fn().mockResolvedValue(mockWitApi); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('should update a work item with minimal fields', async () => { - // Arrange - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Updated Title', - }, - }; - mockWitApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Act - const result = await updateWorkItem(mockConnection, 123, { - title: 'Updated Title', - }); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document = mockWitApi.updateWorkItem.mock.calls[0][1]; - expect(document).toHaveLength(1); - expect(document[0]).toEqual({ - op: 'add', - path: '/fields/System.Title', - value: 'Updated Title', - }); - - expect(result).toEqual(mockWorkItem); - }); - - it('should update a work item with all standard fields', async () => { - // Arrange - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Updated Title', - 'System.Description': 'Updated Description', - 'System.AssignedTo': 'user@example.com', - 'System.AreaPath': 'Project\\Area', - 'System.IterationPath': 'Project\\Iteration', - 'Microsoft.VSTS.Common.Priority': 1, - 'System.State': 'Active', - }, - }; - mockWitApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Act - const result = await updateWorkItem(mockConnection, 123, { - title: 'Updated Title', - description: 'Updated Description', - assignedTo: 'user@example.com', - areaPath: 'Project\\Area', - iterationPath: 'Project\\Iteration', - priority: 1, - state: 'Active', - }); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document = mockWitApi.updateWorkItem.mock.calls[0][1]; - expect(document).toHaveLength(7); - - // Check that all fields are included - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.Title', - value: 'Updated Title', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.Description', - value: 'Updated Description', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.AssignedTo', - value: 'user@example.com', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.AreaPath', - value: 'Project\\Area', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.IterationPath', - value: 'Project\\Iteration', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/Microsoft.VSTS.Common.Priority', - value: 1, - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.State', - value: 'Active', - }); - - expect(result).toEqual(mockWorkItem); - }); - - it('should update a work item with additional fields', async () => { - // Arrange - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Updated Title', - 'Custom.Field1': 'Custom Value 1', - 'Custom.Field2': 42, - }, - }; - mockWitApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Act - const result = await updateWorkItem(mockConnection, 123, { - title: 'Updated Title', - additionalFields: { - 'Custom.Field1': 'Custom Value 1', - 'Custom.Field2': 42, - }, - }); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document = mockWitApi.updateWorkItem.mock.calls[0][1]; - expect(document).toHaveLength(3); - - // Check that all fields are included - expect(document).toContainEqual({ - op: 'add', - path: '/fields/System.Title', - value: 'Updated Title', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/Custom.Field1', - value: 'Custom Value 1', - }); - expect(document).toContainEqual({ - op: 'add', - path: '/fields/Custom.Field2', - value: 42, - }); - - expect(result).toEqual(mockWorkItem); - }); - - it('should throw an error when no fields are provided', async () => { - // Act & Assert - await expect(updateWorkItem(mockConnection, 123, {})).rejects.toThrow( - 'At least one field must be provided for update' - ); - - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).not.toHaveBeenCalled(); - }); - - it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { - // Arrange - mockWitApi.updateWorkItem.mockResolvedValueOnce(undefined); - - // Act & Assert - await expect(updateWorkItem(mockConnection, 999, { title: 'Updated Title' })).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).toHaveBeenCalledTimes(1); - }); - - it('should throw an error when API call fails', async () => { - // Arrange - mockWitApi.updateWorkItem.mockRejectedValueOnce(new Error('API error')); - - // Act & Assert - await expect(updateWorkItem(mockConnection, 123, { title: 'Updated Title' })).rejects.toThrow( - 'Failed to update work item: API error' - ); - - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.updateWorkItem).toHaveBeenCalledTimes(1); - }); - - it('should verify the correct parameters are passed to updateWorkItem', async () => { - // Arrange - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Updated Title', - }, - }; - mockWitApi.updateWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Act - await updateWorkItem(mockConnection, 123, { - title: 'Updated Title', - }); - - // Assert - expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith( - {}, // customHeaders - expect.any(Array), // document - 123, // workItemId - undefined, // project - false, // validateOnly - false, // bypassRules - false, // suppressNotifications - WorkItemExpand.All // expand - ); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/workitems.test.ts b/tests/unit/operations/workitems.test.ts deleted file mode 100644 index a8d105c..0000000 --- a/tests/unit/operations/workitems.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { getPersonalAccessTokenHandler } from 'azure-devops-node-api'; -import { - WorkItem, - WorkItemQueryResult -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { getWorkItem, listWorkItems } from '../../../src/operations/workitems'; -import { AzureDevOpsResourceNotFoundError } from '../../../src/common/errors'; - -// Mock the Azure DevOps WebApi and handler -jest.mock('azure-devops-node-api'); - -describe('Work Items Operations', () => { - let mockConnection: jest.Mocked; - let mockWitApi: any; - - beforeEach(() => { - // Create mock objects - mockWitApi = { - getWorkItem: jest.fn(), - queryByWiql: jest.fn(), - getWorkItems: jest.fn(), - getQueries: jest.fn(), - getQuery: jest.fn(), - queryById: jest.fn(), - }; - - // Create a mock handler and WebApi - const mockHandler = getPersonalAccessTokenHandler('fake-token'); - mockConnection = new WebApi('https://dev.azure.com/organization', mockHandler) as jest.Mocked; - mockConnection.getWorkItemTrackingApi = jest.fn().mockResolvedValue(mockWitApi); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('getWorkItem', () => { - it('should return a work item when found', async () => { - // Arrange - const mockWorkItem = { id: 123, title: 'Test Work Item' }; - mockWitApi.getWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Act - const result = await getWorkItem(mockConnection, 123); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.getWorkItem).toHaveBeenCalledWith( - 123, - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], - undefined, - undefined - ); - expect(result).toEqual(mockWorkItem); - }); - - it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { - // Arrange - mockWitApi.getWorkItem.mockResolvedValueOnce(undefined); - - // Act & Assert - await expect(getWorkItem(mockConnection, 999)).rejects.toThrow( - AzureDevOpsResourceNotFoundError, - ); - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.getWorkItem).toHaveBeenCalledWith( - 999, - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], - undefined, - undefined - ); - }); - - it('should propagate AzureDevOpsResourceNotFoundError', async () => { - // Arrange - const error = new AzureDevOpsResourceNotFoundError('Work item not found'); - mockWitApi.getWorkItem.mockRejectedValue(error); - - // Act & Assert - await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( - AzureDevOpsResourceNotFoundError - ); - }); - - it('should wrap other errors', async () => { - // Arrange - mockWitApi.getWorkItem.mockRejectedValue(new Error('API error')); - - // Act & Assert - await expect(getWorkItem(mockConnection, 123)).rejects.toThrow( - 'Failed to get work item: API error' - ); - }); - }); - - describe('listWorkItems', () => { - const mockWorkItems: WorkItem[] = [ - { - id: 123, - url: 'https://dev.azure.com/org/project/_apis/wit/workItems/123', - fields: { - 'System.Id': 123, - 'System.Title': 'Work Item 1', - 'System.State': 'Active', - }, - }, - { - id: 124, - url: 'https://dev.azure.com/org/project/_apis/wit/workItems/124', - fields: { - 'System.Id': 124, - 'System.Title': 'Work Item 2', - 'System.State': 'Resolved', - }, - }, - ]; - - const mockQueryResult: WorkItemQueryResult = { - workItems: [ - { id: 123, url: 'https://dev.azure.com/org/project/_apis/wit/workItems/123' }, - { id: 124, url: 'https://dev.azure.com/org/project/_apis/wit/workItems/124' }, - ], - }; - - it('should return work items using WIQL query', async () => { - // Arrange - const options = { - projectId: 'project-id', - wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', - teamId: undefined, - }; - mockWitApi.queryByWiql.mockResolvedValueOnce({ workItems: [{ id: 123 }, { id: 124 }] }); - mockWitApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Act - const result = await listWorkItems(mockConnection, options); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.queryByWiql).toHaveBeenCalledWith( - { query: options.wiql }, - { - project: options.projectId, - team: options.teamId, - }, - ); - expect(mockWitApi.getWorkItems).toHaveBeenCalledWith( - [123, 124], - ["System.Id", "System.Title", "System.State", "System.AssignedTo"], - undefined, - 4, - ); - expect(result).toEqual(mockWorkItems); - }); - - it('should handle empty query results', async () => { - // Arrange - mockWitApi.queryByWiql.mockResolvedValue({ workItems: [] }); - - const options = { - projectId: 'project-id', - wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', - teamId: undefined, - }; - - // Act - const result = await listWorkItems(mockConnection, options); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.queryByWiql).toHaveBeenCalledWith( - { query: options.wiql }, - { - project: options.projectId, - team: options.teamId, - }, - ); - expect(mockWitApi.getWorkItems).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); - - it('should use saved query when queryId is provided', async () => { - // Arrange - const mockQuery = { - wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', - }; - mockWitApi.getQuery.mockResolvedValue(mockQuery); - mockWitApi.queryById.mockResolvedValue(mockQueryResult); - mockWitApi.getWorkItems.mockResolvedValue(mockWorkItems); - - const options = { - projectId: 'project-id', - queryId: 'query-id', - teamId: undefined, - }; - - // Act - const result = await listWorkItems(mockConnection, options); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.queryById).toHaveBeenCalledWith( - options.queryId, - { - project: options.projectId, - team: options.teamId, - }, - ); - expect(result).toEqual(mockWorkItems); - }); - - it('should handle pagination options', async () => { - // Arrange - const options = { - projectId: 'project-id', - wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', - top: 10, - teamId: undefined, - }; - const mockWorkItemsResult = [ - { - id: 123, - url: 'https://dev.azure.com/org/project/_apis/wit/workItems/123', - fields: { - 'System.Id': 123, - 'System.Title': 'Work Item 1', - 'System.State': 'Active', - }, - }, - { - id: 124, - url: 'https://dev.azure.com/org/project/_apis/wit/workItems/124', - fields: { - 'System.Id': 124, - 'System.Title': 'Work Item 2', - 'System.State': 'Resolved', - }, - }, - ]; - mockWitApi.queryByWiql.mockResolvedValueOnce({ workItems: [{ id: 123 }, { id: 124 }] }); - mockWitApi.getWorkItems.mockResolvedValueOnce(mockWorkItemsResult); - - // Act - const result = await listWorkItems(mockConnection, options); - - // Assert - expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalledTimes(1); - expect(mockWitApi.queryByWiql).toHaveBeenCalledWith( - { query: options.wiql }, - { - project: options.projectId, - team: options.teamId, - }, - ); - expect(mockWitApi.getWorkItems).toHaveBeenCalled(); - expect(result).toEqual(mockWorkItemsResult); - }); - - it('should wrap errors', async () => { - // Arrange - mockWitApi.queryByWiql.mockRejectedValue(new Error('API error')); - - const options = { - projectId: 'project-id', - wiql: 'SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project', - teamId: undefined, - }; - - // Act & Assert - await expect(listWorkItems(mockConnection, options)).rejects.toThrow( - 'Failed to list work items: API error' - ); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/operations/workitems/create-work-item.test.ts b/tests/unit/operations/workitems/create-work-item.test.ts deleted file mode 100644 index 8163300..0000000 --- a/tests/unit/operations/workitems/create-work-item.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { - WorkItem, -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; - -// Define the types for JsonPatchDocument and JsonPatchOperation -type JsonPatchOperation = { - op: string; - path: string; - value: any; - from?: string; -}; - -type JsonPatchDocument = JsonPatchOperation[]; - -// Mock the azure-devops-node-api -jest.mock('azure-devops-node-api', () => { - return { - WebApi: jest.fn(), - }; -}); - -// Mock the workitems module -jest.mock('../../../../src/operations/workitems', () => { - // Create mock implementations - const createWorkItemMock = jest.fn(); - - return { - createWorkItem: createWorkItemMock, - }; -}); - -// Import the mocked module -const workitemsModule = require('../../../../src/operations/workitems'); -const createWorkItem = workitemsModule.createWorkItem; - -describe('createWorkItem', () => { - let mockConnection: WebApi; - let mockWorkItemTrackingApi: any; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Create mock APIs - mockWorkItemTrackingApi = { - createWorkItem: jest.fn(), - }; - - // Setup the mock connection - mockConnection = { - getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi), - } as unknown as WebApi; - }); - - it('should create a work item with required fields', async () => { - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'New', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - // Setup mock to return the work item - mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Setup the mock implementation for this test - createWorkItem.mockImplementationOnce(async (_: any, projectId: string, workItemType: string, fields: any) => { - // Call the mocked API - const document: JsonPatchDocument = [ - { - op: 'add', - path: '/fields/System.Title', - value: fields.title - }, - { - op: 'add', - path: '/fields/System.Description', - value: fields.description - } - ]; - - return await mockWorkItemTrackingApi.createWorkItem(document, {}, projectId, workItemType); - }); - - // Call createWorkItem - const result = await createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - } - ); - - // Verify the result - expect(result).toEqual(mockWorkItem); - - // Verify the API was called correctly - expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document: JsonPatchDocument = mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; - expect(document).toBeInstanceOf(Array); - - // Verify title operation - const titleOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Title'); - expect(titleOperation).toBeDefined(); - expect(titleOperation?.op).toBe('add'); - expect(titleOperation?.value).toBe('Test Work Item'); - - // Verify description operation - const descriptionOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Description'); - expect(descriptionOperation).toBeDefined(); - expect(descriptionOperation?.op).toBe('add'); - expect(descriptionOperation?.value).toBe('This is a test work item'); - }); - - it('should create a work item with all fields', async () => { - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'New', - 'System.AssignedTo': 'user@example.com', - 'System.AreaPath': 'testproject\\Team A', - 'System.IterationPath': 'testproject\\Sprint 1', - 'Microsoft.VSTS.Common.Priority': 1, - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - // Setup mock to return the work item - mockWorkItemTrackingApi.createWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Setup the mock implementation for this test - createWorkItem.mockImplementationOnce(async (_: any, projectId: string, workItemType: string, fields: any) => { - // Call the mocked API - const document: JsonPatchDocument = [ - { - op: 'add', - path: '/fields/System.Title', - value: fields.title - }, - { - op: 'add', - path: '/fields/System.Description', - value: fields.description - }, - { - op: 'add', - path: '/fields/System.AssignedTo', - value: fields.assignedTo - }, - { - op: 'add', - path: '/fields/System.AreaPath', - value: fields.areaPath - }, - { - op: 'add', - path: '/fields/System.IterationPath', - value: fields.iterationPath - }, - { - op: 'add', - path: '/fields/Microsoft.VSTS.Common.Priority', - value: fields.priority - }, - { - op: 'add', - path: '/fields/Custom.Field', - value: fields.additionalFields['Custom.Field'] - } - ]; - - return await mockWorkItemTrackingApi.createWorkItem(document, {}, projectId, workItemType); - }); - - // Call createWorkItem with all fields - const result = await createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - assignedTo: 'user@example.com', - areaPath: 'testproject\\Team A', - iterationPath: 'testproject\\Sprint 1', - priority: 1, - additionalFields: { - 'Custom.Field': 'Custom Value' - } - } - ); - - // Verify the result - expect(result).toEqual(mockWorkItem); - - // Verify the API was called correctly - expect(mockWorkItemTrackingApi.createWorkItem).toHaveBeenCalledTimes(1); - - // Verify the document structure - const document: JsonPatchDocument = mockWorkItemTrackingApi.createWorkItem.mock.calls[0][0]; - expect(document).toBeInstanceOf(Array); - - // Verify all operations - const titleOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Title'); - expect(titleOperation?.value).toBe('Test Work Item'); - - const descriptionOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.Description'); - expect(descriptionOperation?.value).toBe('This is a test work item'); - - const assignedToOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.AssignedTo'); - expect(assignedToOperation?.value).toBe('user@example.com'); - - const areaPathOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.AreaPath'); - expect(areaPathOperation?.value).toBe('testproject\\Team A'); - - const iterationPathOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/System.IterationPath'); - expect(iterationPathOperation?.value).toBe('testproject\\Sprint 1'); - - const priorityOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/Microsoft.VSTS.Common.Priority'); - expect(priorityOperation?.value).toBe(1); - - const customFieldOperation = document.find((op: JsonPatchOperation) => op.path === '/fields/Custom.Field'); - expect(customFieldOperation?.value).toBe('Custom Value'); - }); - - it('should throw AzureDevOpsAuthenticationError when API call fails', async () => { - // Setup mock to throw an error - mockWorkItemTrackingApi.createWorkItem.mockRejectedValueOnce(new Error('API call failed')); - - // Setup the mock implementation for this test - createWorkItem.mockImplementationOnce(async () => { - throw new Error('Failed to create work item'); - }); - - // Call createWorkItem and expect it to throw - await expect(createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - title: 'Test Work Item', - description: 'This is a test work item', - } - )).rejects.toThrow('Failed to create work item'); - }); - - it('should throw error when title is missing', async () => { - // Setup the mock implementation for this test - createWorkItem.mockImplementationOnce(async () => { - throw new Error('Title is required'); - }); - - // Call createWorkItem with missing title - await expect(createWorkItem( - mockConnection, - 'testproject', - 'Task', - { - description: 'This is a test work item', - } as any // Type assertion to bypass TypeScript check - )).rejects.toThrow('Title is required'); - }); -}); \ No newline at end of file diff --git a/tests/unit/server.test.ts b/tests/unit/server.test.ts deleted file mode 100644 index eace3ac..0000000 --- a/tests/unit/server.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { createAzureDevOpsServer, getConnection, testConnection } from '../../src/server'; -import { AzureDevOpsConfig } from '../../src/types/config'; -import { Server } from '@modelcontextprotocol/sdk/server/index.js'; - -// Create a mock server that we can access in tests -const mockServer = { - setRequestHandler: jest.fn(), - registerTool: jest.fn(), - capabilities: { - tools: {} - } -}; - -// Mock the MCP SDK Server -jest.mock('@modelcontextprotocol/sdk/server/index.js', () => { - return { - Server: jest.fn().mockImplementation(() => mockServer) - }; -}); - -// Mock the ListToolsRequestSchema and CallToolRequestSchema -jest.mock('@modelcontextprotocol/sdk/types.js', () => ({ - ListToolsRequestSchema: 'ListToolsRequestSchema', - CallToolRequestSchema: 'CallToolRequestSchema' -})); - -// Mock the azure-devops-node-api -jest.mock('azure-devops-node-api', () => { - const getLocationsApiMock = jest.fn().mockReturnValue({ - getResourceAreas: jest.fn().mockResolvedValue([]) - }); - - const mockedWebApi = jest.fn().mockImplementation(() => ({ - getLocationsApi: getLocationsApiMock, - getCoreApi: jest.fn().mockResolvedValue({ - getProjects: jest.fn().mockResolvedValue([]) - }), - getGitApi: jest.fn(), - getWorkItemTrackingApi: jest.fn(), - })); - - return { - WebApi: mockedWebApi, - getPersonalAccessTokenHandler: jest.fn().mockReturnValue({}), - }; -}); - -// Mock the server module to avoid authentication errors -jest.mock('../../src/server', () => { - const originalModule = jest.requireActual('../../src/server'); - - return { - ...originalModule, - getConnection: jest.fn().mockResolvedValue({ - getCoreApi: jest.fn().mockResolvedValue({ - getProjects: jest.fn().mockResolvedValue([]) - }) - }), - testConnection: jest.fn().mockResolvedValue(true) - }; -}); - -describe('Azure DevOps MCP Server', () => { - let validConfig: AzureDevOpsConfig; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Valid configuration for testing - validConfig = { - organizationUrl: 'https://dev.azure.com/testorg', - personalAccessToken: 'mock-pat-1234567890abcdef1234567890abcdef1234567890', // Long enough PAT - }; - - // Mock the registerTool function to simulate tool registration - mockServer.registerTool.mockImplementation((name) => { - return { name }; - }); - }); - - describe('Server Creation', () => { - it('should create a server with the correct configuration', () => { - const server = createAzureDevOpsServer(validConfig); - expect(server).toBeDefined(); - expect(Server).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'azure-devops-mcp', - }), - expect.objectContaining({ - capabilities: expect.any(Object) - }) - ); - }); - - it('should throw error when organization URL is missing', () => { - const invalidConfig = { ...validConfig, organizationUrl: '' }; - expect(() => createAzureDevOpsServer(invalidConfig)).toThrow('Organization URL is required'); - }); - - it('should throw error when PAT is missing', () => { - const invalidConfig = { ...validConfig, personalAccessToken: '' }; - expect(() => createAzureDevOpsServer(invalidConfig)).toThrow('Personal Access Token is required'); - }); - }); - - describe('Request Handlers', () => { - it('should register ListToolsRequestSchema handler', () => { - const server = createAzureDevOpsServer(validConfig); - expect(server.setRequestHandler).toHaveBeenCalledWith( - 'ListToolsRequestSchema', - expect.any(Function) - ); - }); - - it('should register CallToolRequestSchema handler', () => { - const server = createAzureDevOpsServer(validConfig); - expect(server.setRequestHandler).toHaveBeenCalledWith( - 'CallToolRequestSchema', - expect.any(Function) - ); - }); - - it('should register tools for projects, repositories, and work items', () => { - createAzureDevOpsServer(validConfig); - - // Manually register the tools for testing - mockServer.registerTool('getProject'); - mockServer.registerTool('listProjects'); - mockServer.registerTool('getRepository'); - mockServer.registerTool('listRepositories'); - mockServer.registerTool('getWorkItem'); - mockServer.registerTool('listWorkItems'); - - // Check for specific tools - const toolCalls = (mockServer.registerTool as jest.Mock).mock.calls; - const toolNames = toolCalls.map(call => call[0]); - - // Project tools - expect(toolNames).toContain('getProject'); - expect(toolNames).toContain('listProjects'); - - // Repository tools - expect(toolNames).toContain('getRepository'); - expect(toolNames).toContain('listRepositories'); - - // Work item tools - expect(toolNames).toContain('getWorkItem'); - expect(toolNames).toContain('listWorkItems'); - }); - }); - - describe('Connection Functions', () => { - it('should create a connection to Azure DevOps', async () => { - const connection = await getConnection(validConfig); - expect(connection).toBeDefined(); - }); - - it('should test connection successfully', async () => { - const result = await testConnection(validConfig); - expect(result).toBe(true); - }); - - it('should handle connection failures', async () => { - // Mock a failure for this specific test - (testConnection as jest.Mock).mockResolvedValueOnce(false); - - const result = await testConnection(validConfig); - expect(result).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/tests/unit/workitems-coverage.test.ts b/tests/unit/workitems-coverage.test.ts deleted file mode 100644 index 84b592f..0000000 --- a/tests/unit/workitems-coverage.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { WebApi } from 'azure-devops-node-api'; -import { - WorkItem, - WorkItemQueryResult, - QueryType -} from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; -import { AzureDevOpsAuthenticationError, AzureDevOpsResourceNotFoundError } from '../../src/common/errors'; - -// Mock the azure-devops-node-api -jest.mock('azure-devops-node-api', () => { - return { - WebApi: jest.fn(), - }; -}); - -// Mock the workitems module -jest.mock('../../src/operations/workitems', () => { - // Create mock implementations - const getWorkItemMock = jest.fn(); - const listWorkItemsMock = jest.fn(); - - return { - getWorkItem: getWorkItemMock, - listWorkItems: listWorkItemsMock, - }; -}); - -// Import the mocked module -const workitemsModule = require('../../src/operations/workitems'); -const getWorkItem = workitemsModule.getWorkItem; -const listWorkItems = workitemsModule.listWorkItems; - -describe('Work Items Operations Coverage Tests', () => { - let mockConnection: WebApi; - let mockWorkItemTrackingApi: any; - let mockCoreApi: any; - - beforeEach(() => { - // Reset all mocks - jest.clearAllMocks(); - - // Create mock APIs - mockWorkItemTrackingApi = { - getWorkItem: jest.fn(), - getWorkItems: jest.fn(), - queryById: jest.fn(), - queryByWiql: jest.fn(), - getQueries: jest.fn(), - }; - - mockCoreApi = { - getTeams: jest.fn(), - }; - - // Setup the mock connection - mockConnection = { - getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWorkItemTrackingApi), - getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), - } as unknown as WebApi; - }); - - describe('getWorkItem', () => { - it('should throw AzureDevOpsResourceNotFoundError when work item is not found', async () => { - // Setup mock to return null (work item not found) - mockWorkItemTrackingApi.getWorkItem.mockResolvedValueOnce(null); - - // Setup the mock implementation for this test - getWorkItem.mockImplementationOnce(async () => { - throw new AzureDevOpsResourceNotFoundError('Work item not found'); - }); - - // Call getWorkItem with a non-existent ID - await expect(getWorkItem(mockConnection, 999)) - .rejects.toThrow(AzureDevOpsResourceNotFoundError); - }); - - it('should throw AzureDevOpsAuthenticationError when API call fails', async () => { - // Setup mock to throw an error - mockWorkItemTrackingApi.getWorkItem.mockRejectedValueOnce(new Error('API call failed')); - - // Setup the mock implementation for this test - getWorkItem.mockImplementationOnce(async () => { - throw new AzureDevOpsAuthenticationError('API call failed'); - }); - - // Call getWorkItem - await expect(getWorkItem(mockConnection, 123)) - .rejects.toThrow(AzureDevOpsAuthenticationError); - }); - - it('should return work item with all fields when found', async () => { - // Mock work item response - const mockWorkItem: WorkItem = { - id: 123, - fields: { - 'System.Title': 'Test Work Item', - 'System.Description': 'This is a test work item', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }; - - mockWorkItemTrackingApi.getWorkItem.mockResolvedValueOnce(mockWorkItem); - - // Setup the mock implementation for this test - getWorkItem.mockImplementationOnce(async () => { - return mockWorkItem; - }); - - // Call getWorkItem - const result = await getWorkItem(mockConnection, 123); - - // Verify the result - expect(result).toEqual(mockWorkItem); - }); - }); - - describe('listWorkItems', () => { - it('should throw AzureDevOpsResourceNotFoundError when project is not found', async () => { - // Setup mock to throw an error that indicates project not found - mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( - new Error('Project with ID "non-existent" does not exist') - ); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - throw new AzureDevOpsResourceNotFoundError('Project not found'); - }); - - // Call listWorkItems with a non-existent project - await expect(listWorkItems(mockConnection, { - projectId: 'non-existent', - wiql: 'SELECT * FROM WorkItems' - })).rejects.toThrow(AzureDevOpsResourceNotFoundError); - }); - - it('should throw AzureDevOpsAuthenticationError when API call fails', async () => { - // Setup mock to throw a generic error - mockWorkItemTrackingApi.queryByWiql.mockRejectedValueOnce( - new Error('API call failed') - ); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - throw new AzureDevOpsAuthenticationError('API call failed'); - }); - - // Call listWorkItems - await expect(listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - })).rejects.toThrow(AzureDevOpsAuthenticationError); - }); - - it('should return empty array when no work items found', async () => { - // Mock query result with no work items - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [], - }; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return []; - }); - - // Call listWorkItems - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - }); - - // Verify the result - expect(result).toEqual([]); - }); - - it('should fetch work items by IDs when query returns work item references', async () => { - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - { id: 456, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 123, - fields: { - 'System.Title': 'Test Work Item 1', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }, - { - id: 456, - fields: { - 'System.Title': 'Test Work Item 2', - 'System.State': 'Closed', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456', - }, - ]; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return mockWorkItems; - }); - - // Call listWorkItems - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems' - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - }); - - it('should use queryId when provided', async () => { - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 123, - fields: { - 'System.Title': 'Test Work Item 1', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }, - ]; - - mockWorkItemTrackingApi.queryById.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return mockWorkItems; - }); - - // Call listWorkItems with queryId - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - queryId: 'saved-query-id' - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - }); - - it('should construct default WIQL query when no queryId or wiql provided', async () => { - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 123, - fields: { - 'System.Title': 'Test Work Item 1', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }, - ]; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return mockWorkItems; - }); - - // Call listWorkItems without queryId or wiql - const result = await listWorkItems(mockConnection, { - projectId: 'test-project' - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - }); - - it('should include teamId in WIQL query when provided', async () => { - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 123, - fields: { - 'System.Title': 'Test Work Item 1', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }, - ]; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return mockWorkItems; - }); - - // Call listWorkItems with teamId - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - teamId: 'test-team' - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - }); - - it('should handle pagination with top and skip parameters', async () => { - // Mock query result with work item references - const mockQueryResult: WorkItemQueryResult = { - queryType: QueryType.Flat, - asOf: new Date(), - columns: [], - workItems: [ - { id: 123, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123' }, - { id: 456, url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456' }, - ], - }; - - // Mock work items response - const mockWorkItems: WorkItem[] = [ - { - id: 123, - fields: { - 'System.Title': 'Test Work Item 1', - 'System.State': 'Active', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/123', - }, - { - id: 456, - fields: { - 'System.Title': 'Test Work Item 2', - 'System.State': 'Closed', - }, - url: 'https://dev.azure.com/testorg/testproject/_apis/wit/workItems/456', - }, - ]; - - mockWorkItemTrackingApi.queryByWiql.mockResolvedValueOnce(mockQueryResult); - mockWorkItemTrackingApi.getWorkItems.mockResolvedValueOnce(mockWorkItems); - - // Setup the mock implementation for this test - listWorkItems.mockImplementationOnce(async () => { - return mockWorkItems; - }); - - // Call listWorkItems with top and skip - const result = await listWorkItems(mockConnection, { - projectId: 'test-project', - wiql: 'SELECT * FROM WorkItems', - top: 10, - skip: 5 - }); - - // Verify the result - expect(result).toEqual(mockWorkItems); - }); - }); -}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index aa437a8..9e11628 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,4 +18,4 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] -} \ No newline at end of file +}