This is page 4 of 6. Use http://codebase.md/tiberriver256/azure-devops-mcp?page={x} to view the full context. # Directory Structure ``` ├── .clinerules ├── .env.example ├── .eslintrc.json ├── .github │ ├── FUNDING.yml │ ├── release-please-config.json │ ├── release-please-manifest.json │ └── workflows │ ├── main.yml │ └── release-please.yml ├── .gitignore ├── .husky │ ├── commit-msg │ └── pre-commit ├── .kilocode │ └── mcp.json ├── .prettierrc ├── .vscode │ └── settings.json ├── CHANGELOG.md ├── commitlint.config.js ├── CONTRIBUTING.md ├── create_branch.sh ├── docs │ ├── authentication.md │ ├── azure-identity-authentication.md │ ├── ci-setup.md │ ├── examples │ │ ├── azure-cli-authentication.env │ │ ├── azure-identity-authentication.env │ │ ├── pat-authentication.env │ │ └── README.md │ ├── testing │ │ ├── README.md │ │ └── setup.md │ └── tools │ ├── core-navigation.md │ ├── organizations.md │ ├── pipelines.md │ ├── projects.md │ ├── pull-requests.md │ ├── README.md │ ├── repositories.md │ ├── resources.md │ ├── search.md │ ├── user-tools.md │ ├── wiki.md │ └── work-items.md ├── finish_task.sh ├── jest.e2e.config.js ├── jest.int.config.js ├── jest.unit.config.js ├── LICENSE ├── memory │ └── tasks_memory_2025-05-26T16-18-03.json ├── package-lock.json ├── package.json ├── project-management │ ├── planning │ │ ├── architecture-guide.md │ │ ├── azure-identity-authentication-design.md │ │ ├── project-plan.md │ │ ├── project-structure.md │ │ ├── tech-stack.md │ │ └── the-dream-team.md │ ├── startup.xml │ ├── tdd-cycle.xml │ └── troubleshooter.xml ├── README.md ├── setup_env.sh ├── shrimp-rules.md ├── src │ ├── clients │ │ └── azure-devops.ts │ ├── features │ │ ├── organizations │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-organizations │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pipelines │ │ │ ├── get-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pipelines │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── trigger-pipeline │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ └── types.ts │ │ ├── projects │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-project │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-project-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-projects │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── pull-requests │ │ │ ├── add-pull-request-comment │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── create-pull-request │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-pull-request-comments │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-pull-requests │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ ├── types.ts │ │ │ └── update-pull-request │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ └── index.ts │ │ ├── repositories │ │ │ ├── __test__ │ │ │ │ └── test-helpers.ts │ │ │ ├── get-all-repositories-tree │ │ │ │ ├── __snapshots__ │ │ │ │ │ └── feature.spec.unit.ts.snap │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-file-content │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-repository-details │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-repositories │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── search │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── search-code │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── search-work-items │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ └── index.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── users │ │ │ ├── get-me │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── schemas.ts │ │ │ ├── tool-definitions.ts │ │ │ └── types.ts │ │ ├── wikis │ │ │ ├── create-wiki │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── create-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wiki-page │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── get-wikis │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── index.spec.unit.ts │ │ │ ├── index.ts │ │ │ ├── list-wiki-pages │ │ │ │ ├── feature.spec.int.ts │ │ │ │ ├── feature.spec.unit.ts │ │ │ │ ├── feature.ts │ │ │ │ ├── index.ts │ │ │ │ └── schema.ts │ │ │ ├── tool-definitions.ts │ │ │ └── update-wiki-page │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ └── work-items │ │ ├── __test__ │ │ │ ├── fixtures.ts │ │ │ ├── test-helpers.ts │ │ │ └── test-utils.ts │ │ ├── create-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── get-work-item │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── index.spec.unit.ts │ │ ├── index.ts │ │ ├── list-work-items │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── manage-work-item-link │ │ │ ├── feature.spec.int.ts │ │ │ ├── feature.spec.unit.ts │ │ │ ├── feature.ts │ │ │ ├── index.ts │ │ │ └── schema.ts │ │ ├── schemas.ts │ │ ├── tool-definitions.ts │ │ ├── types.ts │ │ └── update-work-item │ │ ├── feature.spec.int.ts │ │ ├── feature.spec.unit.ts │ │ ├── feature.ts │ │ ├── index.ts │ │ └── schema.ts │ ├── index.spec.unit.ts │ ├── index.ts │ ├── server.spec.e2e.ts │ ├── server.ts │ ├── shared │ │ ├── api │ │ │ ├── client.ts │ │ │ └── index.ts │ │ ├── auth │ │ │ ├── auth-factory.ts │ │ │ ├── client-factory.ts │ │ │ └── index.ts │ │ ├── config │ │ │ ├── index.ts │ │ │ └── version.ts │ │ ├── enums │ │ │ ├── index.spec.unit.ts │ │ │ └── index.ts │ │ ├── errors │ │ │ ├── azure-devops-errors.ts │ │ │ ├── handle-request-error.ts │ │ │ └── index.ts │ │ ├── test │ │ │ └── test-helpers.ts │ │ └── types │ │ ├── config.ts │ │ ├── index.ts │ │ ├── request-handler.ts │ │ └── tool-definition.ts │ └── utils │ ├── environment.spec.unit.ts │ └── environment.ts ├── tasks.json ├── tests │ └── setup.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /src/features/search/search-code/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import axios from 'axios'; import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, AzureDevOpsPermissionError, AzureDevOpsAuthenticationError, } from '../../../shared/errors'; import { SearchCodeOptions, CodeSearchRequest, CodeSearchResponse, CodeSearchResult, } from '../types'; import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; /** * Search for code in Azure DevOps repositories * * @param connection The Azure DevOps WebApi connection * @param options Parameters for searching code * @returns Search results with optional file content */ export async function searchCode( connection: WebApi, options: SearchCodeOptions, ): Promise<CodeSearchResponse> { try { // When includeContent is true, limit results to prevent timeouts const top = options.includeContent ? Math.min(options.top || 10, 10) : options.top; // Get the project ID (either provided or default) const projectId = options.projectId || process.env.AZURE_DEVOPS_DEFAULT_PROJECT; if (!projectId) { throw new AzureDevOpsValidationError( 'Project ID is required. Either provide a projectId or set the AZURE_DEVOPS_DEFAULT_PROJECT environment variable.', ); } // Prepare the search request const searchRequest: CodeSearchRequest = { searchText: options.searchText, $skip: options.skip, $top: top, // Use limited top value when includeContent is true filters: { Project: [projectId], ...(options.filters || {}), }, includeFacets: true, includeSnippet: options.includeSnippet, }; // Get the authorization header from the connection const authHeader = await getAuthorizationHeader(); // Extract organization from the connection URL const { organization } = extractOrgFromUrl(connection); // Make the search API request with the project ID const searchUrl = `https://almsearch.dev.azure.com/${organization}/${projectId}/_apis/search/codesearchresults?api-version=7.1`; const searchResponse = await axios.post<CodeSearchResponse>( searchUrl, searchRequest, { headers: { Authorization: authHeader, 'Content-Type': 'application/json', }, }, ); const results = searchResponse.data; // If includeContent is true, fetch the content for each result if (options.includeContent && results.results.length > 0) { await enrichResultsWithContent(connection, results.results); } return results; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } if (axios.isAxiosError(error)) { const status = error.response?.status; if (status === 404) { throw new AzureDevOpsResourceNotFoundError( 'Repository or project not found', { cause: error }, ); } if (status === 400) { throw new AzureDevOpsValidationError( 'Invalid search parameters', error.response?.data, { cause: error }, ); } if (status === 401) { throw new AzureDevOpsAuthenticationError('Authentication failed', { cause: error, }); } if (status === 403) { throw new AzureDevOpsPermissionError( 'Permission denied to access repository', { cause: error }, ); } } throw new AzureDevOpsError('Failed to search code', { cause: error }); } } /** * Extract organization from the connection URL * * @param connection The Azure DevOps WebApi connection * @returns The organization */ function extractOrgFromUrl(connection: WebApi): { organization: string } { // Extract organization from the connection URL const url = connection.serverUrl; const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/); const organization = match ? match[1] : ''; if (!organization) { throw new AzureDevOpsValidationError( 'Could not extract organization from connection URL', ); } return { organization, }; } /** * Get the authorization header from the connection * * @returns The authorization header */ async function getAuthorizationHeader(): Promise<string> { try { // For PAT authentication, we can construct the header directly if ( process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' && process.env.AZURE_DEVOPS_PAT ) { // For PAT auth, we can construct the Basic auth header directly const token = process.env.AZURE_DEVOPS_PAT; const base64Token = Buffer.from(`:${token}`).toString('base64'); return `Basic ${base64Token}`; } // For Azure Identity / Azure CLI auth, we need to get a token // using the Azure DevOps resource ID // Choose the appropriate credential based on auth method const credential = process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli' ? new AzureCliCredential() : new DefaultAzureCredential(); // Azure DevOps resource ID for token acquisition const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798'; // Get token for Azure DevOps const token = await credential.getToken( `${AZURE_DEVOPS_RESOURCE_ID}/.default`, ); if (!token || !token.token) { throw new Error('Failed to acquire token for Azure DevOps'); } return `Bearer ${token.token}`; } catch (error) { throw new AzureDevOpsValidationError( `Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Enrich search results with file content * * @param connection The Azure DevOps WebApi connection * @param results The search results to enrich */ async function enrichResultsWithContent( connection: WebApi, results: CodeSearchResult[], ): Promise<void> { try { const gitApi = await connection.getGitApi(); // Process each result in parallel await Promise.all( results.map(async (result) => { try { // Get the file content using the Git API // Pass only the required parameters to avoid the "path" and "scopePath" conflict const contentStream = await gitApi.getItemContent( result.repository.id, result.path, result.project.name, undefined, // No version descriptor object undefined, // No recursion level undefined, // Don't include content metadata undefined, // No latest processed change false, // Don't download { version: result.versions[0]?.changeId, versionType: GitVersionType.Commit, }, // Version descriptor true, // Include content ); // Convert the stream to a string and store it in the result if (contentStream) { // Since getItemContent always returns NodeJS.ReadableStream, we need to read the stream const chunks: Buffer[] = []; // Listen for data events to collect chunks contentStream.on('data', (chunk) => { chunks.push(Buffer.from(chunk)); }); // Use a promise to wait for the stream to finish result.content = await new Promise<string>((resolve, reject) => { contentStream.on('end', () => { // Concatenate all chunks and convert to string const buffer = Buffer.concat(chunks); resolve(buffer.toString('utf8')); }); contentStream.on('error', (err) => { reject(err); }); }); } } catch (error) { // Log the error but don't fail the entire operation console.error( `Failed to fetch content for ${result.path}: ${error instanceof Error ? error.message : String(error)}`, ); } }), ); } catch (error) { // Log the error but don't fail the entire operation console.error( `Failed to enrich results with content: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/shared/api/client.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { ICoreApi } from 'azure-devops-node-api/CoreApi'; import { IGitApi } from 'azure-devops-node-api/GitApi'; import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; import { IBuildApi } from 'azure-devops-node-api/BuildApi'; 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 '../errors'; import { AuthenticationMethod } from '../auth'; import { AzureDevOpsClient as SharedClient } from '../auth/client-factory'; export interface AzureDevOpsClientConfig { orgUrl: string; pat: string; } /** * Azure DevOps Client * * Provides access to Azure DevOps APIs */ export class AzureDevOpsClient { private config: AzureDevOpsClientConfig; private clientPromise: Promise<WebApi> | null = null; constructor(config: AzureDevOpsClientConfig) { this.config = config; } /** * Get the authenticated Azure DevOps client * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ private async getClient(): Promise<WebApi> { if (!this.clientPromise) { this.clientPromise = (async () => { try { 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) { throw error; } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( error instanceof Error ? `Authentication failed: ${error.message}` : 'Authentication failed: Unknown error', ); } })(); } return this.clientPromise; } /** * Check if the client is authenticated * * @returns True if the client is authenticated */ public async isAuthenticated(): Promise<boolean> { try { const client = await this.getClient(); return !!client; } catch { // Any error means we're not authenticated return false; } } /** * Get the Core API * * @returns The Core API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getCoreApi(): Promise<ICoreApi> { try { const client = await this.getClient(); return await client.getCoreApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Git API * * @returns The Git API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getGitApi(): Promise<IGitApi> { try { const client = await this.getClient(); return await client.getGitApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Work Item Tracking API * * @returns The Work Item Tracking API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getWorkItemTrackingApi(): Promise<IWorkItemTrackingApi> { try { const client = await this.getClient(); return await client.getWorkItemTrackingApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Build API * * @returns The Build API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getBuildApi(): Promise<IBuildApi> { try { const client = await this.getClient(); return await client.getBuildApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Test API * * @returns The Test API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTestApi(): Promise<ITestApi> { try { const client = await this.getClient(); return await client.getTestApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Release API * * @returns The Release API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getReleaseApi(): Promise<IReleaseApi> { try { const client = await this.getClient(); return await client.getReleaseApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Task Agent API * * @returns The Task Agent API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTaskAgentApi(): Promise<ITaskAgentApi> { try { const client = await this.getClient(); return await client.getTaskAgentApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Task API * * @returns The Task API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTaskApi(): Promise<ITaskApi> { try { const client = await this.getClient(); return await client.getTaskApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } } ``` -------------------------------------------------------------------------------- /src/server.spec.e2e.ts: -------------------------------------------------------------------------------- ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { spawn } from 'child_process'; import { join } from 'path'; import dotenv from 'dotenv'; import { Organization } from './features/organizations/types'; import fs from 'fs'; // Load environment variables from .env file dotenv.config(); describe('Azure DevOps MCP Server E2E Tests', () => { let client: Client; let serverProcess: ReturnType<typeof spawn>; let transport: StdioClientTransport; let tempEnvFile: string | null = null; beforeAll(async () => { // Debug: Log environment variables console.error('E2E TEST ENVIRONMENT VARIABLES:'); console.error( `AZURE_DEVOPS_ORG_URL: ${process.env.AZURE_DEVOPS_ORG_URL || 'NOT SET'}`, ); console.error( `AZURE_DEVOPS_PAT: ${process.env.AZURE_DEVOPS_PAT ? 'SET (hidden value)' : 'NOT SET'}`, ); console.error( `AZURE_DEVOPS_DEFAULT_PROJECT: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'NOT SET'}`, ); console.error( `AZURE_DEVOPS_AUTH_METHOD: ${process.env.AZURE_DEVOPS_AUTH_METHOD || 'NOT SET'}`, ); // Start the MCP server process const serverPath = join(process.cwd(), 'dist', 'index.js'); // Create a temporary .env file for testing if needed const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || ''; const pat = process.env.AZURE_DEVOPS_PAT || ''; const defaultProject = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || ''; const authMethod = process.env.AZURE_DEVOPS_AUTH_METHOD || 'pat'; if (orgUrl) { // Create a temporary .env file for the test tempEnvFile = join(process.cwd(), '.env.e2e-test'); const envFileContent = ` AZURE_DEVOPS_ORG_URL=${orgUrl} AZURE_DEVOPS_PAT=${pat} AZURE_DEVOPS_DEFAULT_PROJECT=${defaultProject} AZURE_DEVOPS_AUTH_METHOD=${authMethod} `; fs.writeFileSync(tempEnvFile, envFileContent); console.error(`Created temporary .env file at ${tempEnvFile}`); // Start server with explicit file path to the temp .env file serverProcess = spawn('node', ['-r', 'dotenv/config', serverPath], { env: { ...process.env, NODE_ENV: 'test', DOTENV_CONFIG_PATH: tempEnvFile, }, }); } else { throw new Error( 'Cannot start server: AZURE_DEVOPS_ORG_URL is not set in the environment', ); } // Capture server output for debugging if (serverProcess && serverProcess.stderr) { serverProcess.stderr.on('data', (data) => { console.error(`Server error: ${data.toString()}`); }); } // Give the server a moment to start await new Promise((resolve) => setTimeout(resolve, 1000)); // Connect the MCP client to the server transport = new StdioClientTransport({ command: 'node', args: ['-r', 'dotenv/config', serverPath], env: { ...process.env, NODE_ENV: 'test', DOTENV_CONFIG_PATH: tempEnvFile, }, }); client = new Client( { name: 'e2e-test-client', version: '1.0.0', }, { capabilities: { tools: {}, }, }, ); await client.connect(transport); }); afterAll(async () => { // Clean up the client transport if (transport) { await transport.close(); } // Clean up the client if (client) { await client.close(); } // Clean up the server process if (serverProcess) { serverProcess.kill(); } // Clean up temporary env file if (tempEnvFile && fs.existsSync(tempEnvFile)) { fs.unlinkSync(tempEnvFile); console.error(`Deleted temporary .env file at ${tempEnvFile}`); } // Force exit to clean up any remaining handles await new Promise<void>((resolve) => { setTimeout(() => { resolve(); }, 500); }); }); describe('Organizations', () => { test('should list organizations', async () => { // Arrange // No specific arrangement needed for this test as we're just listing organizations // Act const result = await client.callTool({ name: 'list_organizations', arguments: {}, }); // Assert expect(result).toBeDefined(); // Access the content safely const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Parse the result content const resultText = content[0].text; const organizations: Organization[] = JSON.parse(resultText); // Verify the response structure expect(Array.isArray(organizations)).toBe(true); if (organizations.length > 0) { const firstOrg = organizations[0]; expect(firstOrg).toHaveProperty('id'); expect(firstOrg).toHaveProperty('name'); expect(firstOrg).toHaveProperty('url'); } }); }); describe('Parameterless Tools', () => { test('should call list_organizations without arguments', async () => { // Act - call the tool without providing arguments const result = await client.callTool({ name: 'list_organizations', // No arguments provided arguments: {}, }); // Assert expect(result).toBeDefined(); const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Verify we got a valid JSON response const resultText = content[0].text; const organizations = JSON.parse(resultText); expect(Array.isArray(organizations)).toBe(true); }); test('should call get_me without arguments', async () => { // Act - call the tool without providing arguments const result = await client.callTool({ name: 'get_me', // No arguments provided arguments: {}, }); // Assert expect(result).toBeDefined(); const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Verify we got a valid JSON response with user info const resultText = content[0].text; const userInfo = JSON.parse(resultText); expect(userInfo).toHaveProperty('id'); expect(userInfo).toHaveProperty('displayName'); }); }); describe('Tools with Optional Parameters', () => { test('should call list_projects without arguments', async () => { // Act - call the tool without providing arguments const result = await client.callTool({ name: 'list_projects', // No arguments provided arguments: {}, }); // Assert expect(result).toBeDefined(); const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Verify we got a valid JSON response const resultText = content[0].text; const projects = JSON.parse(resultText); expect(Array.isArray(projects)).toBe(true); }); test('should call get_project without arguments', async () => { // Act - call the tool without providing arguments const result = await client.callTool({ name: 'get_project', // No arguments provided arguments: {}, }); // Assert expect(result).toBeDefined(); const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Verify we got a valid JSON response with project info const resultText = content[0].text; const project = JSON.parse(resultText); expect(project).toHaveProperty('id'); expect(project).toHaveProperty('name'); }); test('should call list_repositories without arguments', async () => { // Act - call the tool without providing arguments const result = await client.callTool({ name: 'list_repositories', // No arguments provided arguments: {}, }); // Assert expect(result).toBeDefined(); const content = result.content as Array<{ type: string; text: string }>; expect(content).toBeDefined(); expect(content.length).toBeGreaterThan(0); // Verify we got a valid JSON response const resultText = content[0].text; const repositories = JSON.parse(resultText); expect(Array.isArray(repositories)).toBe(true); }); }); }); ``` -------------------------------------------------------------------------------- /src/features/repositories/get-repository-details/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { getRepositoryDetails } from './feature'; import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { GitRepository, GitBranchStats, GitRef } from '../types'; // Unit tests should only focus on isolated logic // No real connections, HTTP requests, or dependencies describe('getRepositoryDetails unit', () => { // Mock repository data const mockRepository: GitRepository = { id: 'repo-id', name: 'test-repo', url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id', project: { id: 'project-id', name: 'test-project', }, defaultBranch: 'refs/heads/main', size: 1024, remoteUrl: 'https://dev.azure.com/org/project/_git/test-repo', sshUrl: '[email protected]:v3/org/project/test-repo', webUrl: 'https://dev.azure.com/org/project/_git/test-repo', }; // Mock branch stats data const mockBranchStats: GitBranchStats[] = [ { name: 'refs/heads/main', aheadCount: 0, behindCount: 0, isBaseVersion: true, commit: { commitId: 'commit-id', author: { name: 'Test User', email: '[email protected]', date: new Date(), }, committer: { name: 'Test User', email: '[email protected]', date: new Date(), }, comment: 'Test commit', }, }, ]; // Mock refs data const mockRefs: GitRef[] = [ { name: 'refs/heads/main', objectId: 'commit-id', creator: { displayName: 'Test User', id: 'user-id', }, url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id/refs/heads/main', }, ]; test('should return basic repository information when no additional options are specified', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), })), }; // Act const result = await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', }); // Assert expect(result).toBeDefined(); expect(result.repository).toEqual(mockRepository); expect(result.statistics).toBeUndefined(); expect(result.refs).toBeUndefined(); }); test('should include branch statistics when includeStatistics is true', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getBranches: jest.fn().mockResolvedValue(mockBranchStats), })), }; // Act const result = await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeStatistics: true, }); // Assert expect(result).toBeDefined(); expect(result.repository).toEqual(mockRepository); expect(result.statistics).toBeDefined(); expect(result.statistics?.branches).toEqual(mockBranchStats); expect(result.refs).toBeUndefined(); }); test('should include refs when includeRefs is true', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getRefs: jest.fn().mockResolvedValue(mockRefs), })), }; // Act const result = await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeRefs: true, }); // Assert expect(result).toBeDefined(); expect(result.repository).toEqual(mockRepository); expect(result.statistics).toBeUndefined(); expect(result.refs).toBeDefined(); expect(result.refs?.value).toEqual(mockRefs); expect(result.refs?.count).toBe(mockRefs.length); }); test('should include both statistics and refs when both options are true', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getBranches: jest.fn().mockResolvedValue(mockBranchStats), getRefs: jest.fn().mockResolvedValue(mockRefs), })), }; // Act const result = await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeStatistics: true, includeRefs: true, }); // Assert expect(result).toBeDefined(); expect(result.repository).toEqual(mockRepository); expect(result.statistics).toBeDefined(); expect(result.statistics?.branches).toEqual(mockBranchStats); expect(result.refs).toBeDefined(); expect(result.refs?.value).toEqual(mockRefs); expect(result.refs?.count).toBe(mockRefs.length); }); test('should pass refFilter to getRefs when provided', async () => { // Arrange const getRefs = jest.fn().mockResolvedValue(mockRefs); const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getRefs, })), }; // Act await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeRefs: true, refFilter: 'heads/', }); // Assert expect(getRefs).toHaveBeenCalledWith( mockRepository.id, 'test-project', 'heads/', ); }); test('should pass branchName to getBranches when provided', async () => { // Arrange const getBranches = jest.fn().mockResolvedValue(mockBranchStats); const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getBranches, })), }; // Act await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeStatistics: true, branchName: 'main', }); // Assert expect(getBranches).toHaveBeenCalledWith( mockRepository.id, 'test-project', { version: 'main', versionType: GitVersionType.Branch, }, ); }); test('should propagate resource not found errors', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(null), // Simulate repository not found })), }; // Act & Assert await expect( getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'non-existent-repo', }), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); await expect( getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'non-existent-repo', }), ).rejects.toThrow( "Repository 'non-existent-repo' not found in project 'test-project'", ); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), }; // Act & Assert await expect( getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', }), ).rejects.toThrow(AzureDevOpsError); await expect( getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', }), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), }; // Act & Assert await expect( getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', }), ).rejects.toThrow('Failed to get repository details: Unexpected error'); }); test('should handle null refs gracefully', async () => { // Arrange const mockConnection: any = { getGitApi: jest.fn().mockImplementation(() => ({ getRepository: jest.fn().mockResolvedValue(mockRepository), getRefs: jest.fn().mockResolvedValue(null), // Simulate null refs })), }; // Act const result = await getRepositoryDetails(mockConnection, { projectId: 'test-project', repositoryId: 'test-repo', includeRefs: true, }); // Assert expect(result).toBeDefined(); expect(result.repository).toEqual(mockRepository); expect(result.refs).toBeDefined(); expect(result.refs?.value).toEqual([]); expect(result.refs?.count).toBe(0); }); }); ``` -------------------------------------------------------------------------------- /memory/tasks_memory_2025-05-26T16-18-03.json: -------------------------------------------------------------------------------- ```json { "tasks": [ { "id": "1c881b0f-7fd6-4184-89f5-1676a56e3719", "name": "Fix shared type definitions with explicit any warnings", "description": "Replace explicit 'any' types in shared type definitions with proper TypeScript types to resolve ESLint warnings. This includes RequestHandler return type and ToolDefinition inputSchema type.", "notes": "These are core type definitions used throughout the project, so changes must maintain backward compatibility. The CallToolResult type is the standard MCP SDK return type for tool responses.", "status": "completed", "dependencies": [], "createdAt": "2025-05-26T15:23:09.065Z", "updatedAt": "2025-05-26T15:33:17.167Z", "relatedFiles": [ { "path": "src/shared/types/request-handler.ts", "type": "TO_MODIFY", "description": "Contains RequestHandler interface with 'any' return type", "lineStart": 14, "lineEnd": 16 }, { "path": "src/shared/types/tool-definition.ts", "type": "TO_MODIFY", "description": "Contains ToolDefinition interface with 'any' inputSchema type", "lineStart": 4, "lineEnd": 8 } ], "implementationGuide": "1. Update src/shared/types/request-handler.ts line 15: Change 'any' to 'Promise<CallToolResult>' where CallToolResult is imported from '@modelcontextprotocol/sdk/types.js'\n2. Update src/shared/types/tool-definition.ts line 7: Change 'any' to 'JSONSchema7' where JSONSchema7 is imported from 'json-schema'\n3. Add necessary imports at the top of each file\n4. Ensure all existing functionality remains unchanged", "verificationCriteria": "1. ESLint warnings for these files should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Import statements should be properly added", "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", "summary": "Successfully replaced explicit 'any' types in shared type definitions with proper TypeScript types. Updated RequestHandler return type to use CallToolResult union type for backward compatibility, and ToolDefinition inputSchema to use JsonSchema7Type from zod-to-json-schema. ESLint warnings for these files are resolved, TypeScript compilation succeeds for the core types, and all existing functionality remains unchanged with proper imports added.", "completedAt": "2025-05-26T15:33:17.165Z" }, { "id": "17aa94fe-24d4-4a8b-a127-ef27e121de38", "name": "Fix Azure DevOps client type warnings", "description": "Replace 'any' types in Azure DevOps client files with proper types from the azure-devops-node-api library to resolve 7 ESLint warnings in src/clients/azure-devops.ts.", "notes": "The azure-devops-node-api library provides comprehensive TypeScript interfaces. Prefer using existing library types over creating custom ones.", "status": "completed", "dependencies": [ { "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719" } ], "createdAt": "2025-05-26T15:23:09.065Z", "updatedAt": "2025-05-26T15:39:30.003Z", "relatedFiles": [ { "path": "src/clients/azure-devops.ts", "type": "TO_MODIFY", "description": "Contains 7 'any' type warnings that need proper typing", "lineStart": 1, "lineEnd": 500 } ], "implementationGuide": "1. Examine each 'any' usage in src/clients/azure-devops.ts at lines 78, 158, 244, 305, 345, 453, 484\n2. Replace with appropriate types from azure-devops-node-api interfaces\n3. Common patterns: Use TeamProject, GitRepository, WorkItem, BuildDefinition types\n4. For API responses, use the specific interface types provided by the library\n5. If no specific type exists, create a minimal interface with required properties", "verificationCriteria": "1. All 7 ESLint warnings in src/clients/azure-devops.ts should be resolved\n2. TypeScript compilation should succeed\n3. Existing functionality should remain unchanged\n4. Types should be imported from azure-devops-node-api where available", "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", "summary": "Successfully fixed all 7 ESLint warnings in src/clients/azure-devops.ts by replacing 'any' types with proper TypeScript interfaces. Created AzureDevOpsApiErrorResponse interface for Azure DevOps API error responses and replaced Record<string, any> with Record<string, string> for payload objects. All ESLint warnings are now resolved while maintaining existing functionality and backward compatibility.", "completedAt": "2025-05-26T15:39:30.002Z" }, { "id": "d971e510-94cc-4f12-a1e8-a0ac35d57b7f", "name": "Fix feature-specific type warnings", "description": "Replace 'any' types in feature modules with proper Azure DevOps API types to resolve remaining ESLint warnings in projects, pull-requests, and repositories features.", "notes": "Each feature module should use the most specific Azure DevOps API type available. Check existing working features for type usage patterns.", "status": "completed", "dependencies": [ { "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719" } ], "createdAt": "2025-05-26T15:23:09.065Z", "updatedAt": "2025-05-26T15:51:24.788Z", "relatedFiles": [ { "path": "src/features/projects/get-project-details/feature.ts", "type": "TO_MODIFY", "description": "Contains 'any' type warning at line 198" }, { "path": "src/features/pull-requests/types.ts", "type": "TO_MODIFY", "description": "Contains 'any' type warnings at lines 20, 83" }, { "path": "src/features/pull-requests/update-pull-request/feature.ts", "type": "TO_MODIFY", "description": "Contains 'any' type warnings at lines 33, 144, 213, 254" }, { "path": "src/features/repositories/get-all-repositories-tree/feature.ts", "type": "TO_MODIFY", "description": "Contains 'any' type warning at line 231" }, { "path": "src/shared/auth/client-factory.ts", "type": "TO_MODIFY", "description": "Contains 'any' type warning at line 282" } ], "implementationGuide": "1. Fix src/features/projects/get-project-details/feature.ts line 198: Use TeamProject or TeamProjectReference type\n2. Fix src/features/pull-requests/types.ts lines 20, 83: Use GitPullRequest related interfaces\n3. Fix src/features/pull-requests/update-pull-request/feature.ts lines 33, 144, 213, 254: Use GitPullRequest and JsonPatchOperation types\n4. Fix src/features/repositories/get-all-repositories-tree/feature.ts line 231: Use GitTreeRef or GitItem type\n5. Fix src/shared/auth/client-factory.ts line 282: Use proper authentication credential type\n6. Import types from azure-devops-node-api/interfaces/", "verificationCriteria": "1. All remaining ESLint 'any' type warnings should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Types should be consistent with Azure DevOps API documentation", "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.", "summary": "Successfully fixed all feature-specific type warnings by replacing 'any' types with proper Azure DevOps API types. Fixed src/features/projects/get-project-details/feature.ts by using WorkItemTypeField interface, src/features/pull-requests/types.ts by replacing 'any' with specific union types, src/features/pull-requests/update-pull-request/feature.ts by using WebApi, AuthenticationMethod, and WorkItemRelation types, src/features/repositories/get-all-repositories-tree/feature.ts by using IGitApi type, and src/shared/auth/client-factory.ts by using IProfileApi type. All ESLint 'any' type warnings in the specified files have been resolved while maintaining type safety and consistency with Azure DevOps API documentation.", "completedAt": "2025-05-26T15:51:24.787Z" } ] } ``` -------------------------------------------------------------------------------- /src/shared/auth/client-factory.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { ICoreApi } from 'azure-devops-node-api/CoreApi'; import { IGitApi } from 'azure-devops-node-api/GitApi'; import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; import { IBuildApi } from 'azure-devops-node-api/BuildApi'; 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 { IProfileApi } from 'azure-devops-node-api/ProfileApi'; 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 { private config: AuthConfig; private clientPromise: Promise<WebApi> | null = null; /** * Creates a new Azure DevOps client * * @param config Authentication configuration */ constructor(config: AuthConfig) { this.config = config; } /** * Get the authenticated Azure DevOps client * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ private async getClient(): Promise<WebApi> { if (!this.clientPromise) { this.clientPromise = (async () => { try { return await createAuthClient(this.config); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( error instanceof Error ? `Authentication failed: ${error.message}` : 'Authentication failed: Unknown error', ); } })(); } return this.clientPromise; } /** * Get the underlying WebApi client * * @returns The authenticated WebApi client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getWebApiClient(): Promise<WebApi> { return this.getClient(); } /** * Check if the client is authenticated * * @returns True if the client is authenticated */ public async isAuthenticated(): Promise<boolean> { try { const client = await this.getClient(); return !!client; } catch { // Any error means we're not authenticated return false; } } /** * Get the Core API * * @returns The Core API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getCoreApi(): Promise<ICoreApi> { try { const client = await this.getClient(); return await client.getCoreApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Git API * * @returns The Git API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getGitApi(): Promise<IGitApi> { try { const client = await this.getClient(); return await client.getGitApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Work Item Tracking API * * @returns The Work Item Tracking API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getWorkItemTrackingApi(): Promise<IWorkItemTrackingApi> { try { const client = await this.getClient(); return await client.getWorkItemTrackingApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Build API * * @returns The Build API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getBuildApi(): Promise<IBuildApi> { try { const client = await this.getClient(); return await client.getBuildApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Test API * * @returns The Test API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTestApi(): Promise<ITestApi> { try { const client = await this.getClient(); return await client.getTestApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Release API * * @returns The Release API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getReleaseApi(): Promise<IReleaseApi> { try { const client = await this.getClient(); return await client.getReleaseApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Task Agent API * * @returns The Task Agent API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTaskAgentApi(): Promise<ITaskAgentApi> { try { const client = await this.getClient(); return await client.getTaskAgentApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Task API * * @returns The Task API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getTaskApi(): Promise<ITaskApi> { try { const client = await this.getClient(); return await client.getTaskApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // 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', ); } } /** * Get the Profile API * * @returns The Profile API client * @throws {AzureDevOpsAuthenticationError} If authentication fails */ public async getProfileApi(): Promise<IProfileApi> { try { const client = await this.getClient(); return await client.getProfileApi(); } catch (error) { // If it's already an AzureDevOpsError, rethrow it if (error instanceof AzureDevOpsError) { throw error; } // Otherwise, wrap it in an AzureDevOpsAuthenticationError throw new AzureDevOpsAuthenticationError( error instanceof Error ? `Failed to get Profile API: ${error.message}` : 'Failed to get Profile API: Unknown error', ); } } } ``` -------------------------------------------------------------------------------- /src/features/pull-requests/update-pull-request/feature.ts: -------------------------------------------------------------------------------- ```typescript import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { WebApi } from 'azure-devops-node-api'; import { WorkItemRelation, WorkItemExpand, } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; import { AzureDevOpsClient } from '../../../shared/auth/client-factory'; import { AzureDevOpsError } from '../../../shared/errors'; import { UpdatePullRequestOptions } from '../types'; import { AuthenticationMethod } from '../../../shared/auth/auth-factory'; import { pullRequestStatusMapper } from '../../../shared/enums'; /** * Updates an existing pull request in Azure DevOps with the specified changes. * * @param options - The options for updating the pull request * @returns The updated pull request */ export const updatePullRequest = async ( options: UpdatePullRequestOptions, ): Promise<GitPullRequest> => { const { projectId, repositoryId, pullRequestId, title, description, status, isDraft, addWorkItemIds, removeWorkItemIds, addReviewers, removeReviewers, additionalProperties, } = options; try { // Get connection to Azure DevOps const client = new AzureDevOpsClient({ method: (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthenticationMethod) ?? 'pat', organizationUrl: process.env.AZURE_DEVOPS_ORG_URL ?? '', personalAccessToken: process.env.AZURE_DEVOPS_PAT, }); const connection = await client.getWebApiClient(); // Get the Git API client const gitApi = await connection.getGitApi(); // First, get the current pull request const pullRequest = await gitApi.getPullRequestById( pullRequestId, projectId, ); if (!pullRequest) { throw new AzureDevOpsError( `Pull request ${pullRequestId} not found in repository ${repositoryId}`, ); } // Store the artifactId for work item linking const artifactId = pullRequest.artifactId; // Create an object with the properties to update const updateObject: Partial<GitPullRequest> = {}; if (title !== undefined) { updateObject.title = title; } if (description !== undefined) { updateObject.description = description; } if (isDraft !== undefined) { updateObject.isDraft = isDraft; } if (status) { const enumStatus = pullRequestStatusMapper.toEnum(status); if (enumStatus !== undefined) { updateObject.status = enumStatus; } else { throw new AzureDevOpsError( `Invalid status: ${status}. Valid values are: active, abandoned, completed`, ); } } // Add any additional properties that were specified if (additionalProperties) { Object.assign(updateObject, additionalProperties); } // Update the pull request const updatedPullRequest = await gitApi.updatePullRequest( updateObject, repositoryId, pullRequestId, projectId, ); // Handle work items separately if needed const addIds = addWorkItemIds ?? []; const removeIds = removeWorkItemIds ?? []; if (addIds.length > 0 || removeIds.length > 0) { await handleWorkItems({ connection, pullRequestId, repositoryId, projectId, workItemIdsToAdd: addIds, workItemIdsToRemove: removeIds, artifactId, }); } // Handle reviewers separately if needed const addReviewerIds = addReviewers ?? []; const removeReviewerIds = removeReviewers ?? []; if (addReviewerIds.length > 0 || removeReviewerIds.length > 0) { await handleReviewers({ connection, pullRequestId, repositoryId, projectId, reviewersToAdd: addReviewerIds, reviewersToRemove: removeReviewerIds, }); } return updatedPullRequest; } catch (error) { throw new AzureDevOpsError( `Failed to update pull request ${pullRequestId} in repository ${repositoryId}: ${error instanceof Error ? error.message : String(error)}`, ); } }; /** * Handle adding or removing work items from a pull request */ interface WorkItemHandlingOptions { connection: WebApi; pullRequestId: number; repositoryId: string; projectId?: string; workItemIdsToAdd: number[]; workItemIdsToRemove: number[]; artifactId?: string; } async function handleWorkItems( options: WorkItemHandlingOptions, ): Promise<void> { const { connection, pullRequestId, repositoryId, projectId, workItemIdsToAdd, workItemIdsToRemove, artifactId, } = options; try { // For each work item to add, create a link if (workItemIdsToAdd.length > 0) { const workItemTrackingApi = await connection.getWorkItemTrackingApi(); for (const workItemId of workItemIdsToAdd) { // Add the relationship between the work item and pull request await workItemTrackingApi.updateWorkItem( null, [ { op: 'add', path: '/relations/-', value: { rel: 'ArtifactLink', // Use the artifactId if available, otherwise fall back to the old format url: artifactId || `vstfs:///Git/PullRequestId/${projectId ?? ''}/${repositoryId}/${pullRequestId}`, attributes: { name: 'Pull Request', }, }, }, ], workItemId, ); } } // For each work item to remove, remove the link if (workItemIdsToRemove.length > 0) { const workItemTrackingApi = await connection.getWorkItemTrackingApi(); for (const workItemId of workItemIdsToRemove) { try { // First, get the work item with relations expanded const workItem = await workItemTrackingApi.getWorkItem( workItemId, undefined, // fields undefined, // asOf WorkItemExpand.Relations, ); if (workItem.relations) { // Find the relationship to the pull request using the artifactId const prRelationIndex = workItem.relations.findIndex( (rel: WorkItemRelation) => rel.rel === 'ArtifactLink' && rel.attributes && rel.attributes.name === 'Pull Request' && rel.url === artifactId, ); if (prRelationIndex !== -1) { // Remove the relationship await workItemTrackingApi.updateWorkItem( null, [ { op: 'remove', path: `/relations/${prRelationIndex}`, }, ], workItemId, ); } } } catch (error) { console.log( `Error removing work item ${workItemId} from pull request ${pullRequestId}: ${ error instanceof Error ? error.message : String(error) }`, ); } } } } catch (error) { throw new AzureDevOpsError( `Failed to update work item links for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Handle adding or removing reviewers from a pull request */ interface ReviewerHandlingOptions { connection: WebApi; pullRequestId: number; repositoryId: string; projectId?: string; reviewersToAdd: string[]; reviewersToRemove: string[]; } async function handleReviewers( options: ReviewerHandlingOptions, ): Promise<void> { const { connection, pullRequestId, repositoryId, projectId, reviewersToAdd, reviewersToRemove, } = options; try { const gitApi = await connection.getGitApi(); // Add reviewers if (reviewersToAdd.length > 0) { for (const reviewer of reviewersToAdd) { try { // Create a reviewer object with the identifier await gitApi.createPullRequestReviewer( { id: reviewer, // This can be email or ID isRequired: false, }, repositoryId, pullRequestId, reviewer, projectId, ); } catch (error) { console.log( `Error adding reviewer ${reviewer} to pull request ${pullRequestId}: ${ error instanceof Error ? error.message : String(error) }`, ); } } } // Remove reviewers if (reviewersToRemove.length > 0) { for (const reviewer of reviewersToRemove) { try { await gitApi.deletePullRequestReviewer( repositoryId, pullRequestId, reviewer, projectId, ); } catch (error) { console.log( `Error removing reviewer ${reviewer} from pull request ${pullRequestId}: ${ error instanceof Error ? error.message : String(error) }`, ); } } } } catch (error) { throw new AzureDevOpsError( `Failed to update reviewers for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /src/features/pull-requests/get-pull-request-comments/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPullRequestComments } from './feature'; import { listPullRequests } from '../list-pull-requests/feature'; import { addPullRequestComment } from '../add-pull-request-comment/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; describe('getPullRequestComments integration', () => { let connection: WebApi | null = null; let projectName: string; let repositoryName: string; let pullRequestId: number; let testThreadId: number; // Generate unique identifiers using timestamp for comment content const timestamp = Date.now(); const randomSuffix = Math.floor(Math.random() * 1000); beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); // Set up project and repository names from environment projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || ''; // Skip setup if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { return; } try { // Find an active pull request to use for testing const pullRequests = await listPullRequests( connection, projectName, repositoryName, { projectId: projectName, repositoryId: repositoryName, status: 'active', top: 1, }, ); if (!pullRequests || pullRequests.value.length === 0) { throw new Error('No active pull requests found for testing'); } pullRequestId = pullRequests.value[0].pullRequestId!; console.log(`Using existing pull request #${pullRequestId} for testing`); // Create a test comment thread that we can use for specific thread tests const result = await addPullRequestComment( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, content: `Test comment thread ${timestamp}-${randomSuffix}`, status: 'active', }, ); testThreadId = result.thread!.id!; console.log(`Created test comment thread #${testThreadId} for testing`); } catch (error) { console.error('Error in test setup:', error); throw error; } }); test('should get all comment threads from pull request with file path and line number', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } const threads = await getPullRequestComments( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, }, ); // Verify threads were returned expect(threads).toBeDefined(); expect(Array.isArray(threads)).toBe(true); expect(threads.length).toBeGreaterThan(0); // Verify thread structure const firstThread = threads[0]; expect(firstThread.id).toBeDefined(); expect(firstThread.comments).toBeDefined(); expect(Array.isArray(firstThread.comments)).toBe(true); expect(firstThread.comments!.length).toBeGreaterThan(0); // Verify comment structure including new fields const firstComment = firstThread.comments![0]; expect(firstComment.content).toBeDefined(); expect(firstComment.id).toBeDefined(); expect(firstComment.publishedDate).toBeDefined(); expect(firstComment.author).toBeDefined(); // Verify new fields are present (may be undefined/null for general comments) expect(firstComment).toHaveProperty('filePath'); expect(firstComment).toHaveProperty('rightFileStart'); expect(firstComment).toHaveProperty('rightFileEnd'); expect(firstComment).toHaveProperty('leftFileStart'); expect(firstComment).toHaveProperty('leftFileEnd'); }, 30000); test('should get a specific comment thread by ID with file path and line number', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } const threads = await getPullRequestComments( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, threadId: testThreadId, }, ); // Verify only one thread was returned expect(threads).toBeDefined(); expect(Array.isArray(threads)).toBe(true); expect(threads.length).toBe(1); // Verify it's the correct thread const thread = threads[0]; expect(thread.id).toBe(testThreadId); expect(thread.comments).toBeDefined(); expect(Array.isArray(thread.comments)).toBe(true); expect(thread.comments!.length).toBeGreaterThan(0); // Verify the comment content matches what we created const comment = thread.comments![0]; expect(comment.content).toBe( `Test comment thread ${timestamp}-${randomSuffix}`, ); // Verify new fields are present (may be undefined/null for general comments) expect(comment).toHaveProperty('filePath'); expect(comment).toHaveProperty('rightFileStart'); expect(comment).toHaveProperty('rightFileEnd'); expect(comment).toHaveProperty('leftFileStart'); expect(comment).toHaveProperty('leftFileEnd'); }, 30000); test('should handle pagination with top parameter', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } // Get all threads first to compare const allThreads = await getPullRequestComments( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, }, ); // Then get with pagination const paginatedThreads = await getPullRequestComments( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, top: 1, }, ); // Verify pagination expect(paginatedThreads).toBeDefined(); expect(Array.isArray(paginatedThreads)).toBe(true); expect(paginatedThreads.length).toBe(1); expect(paginatedThreads.length).toBeLessThanOrEqual(allThreads.length); // Verify the thread structure is the same const thread = paginatedThreads[0]; expect(thread.id).toBeDefined(); expect(thread.comments).toBeDefined(); expect(Array.isArray(thread.comments)).toBe(true); expect(thread.comments!.length).toBeGreaterThan(0); // Verify new fields are present in paginated results const comment = thread.comments![0]; expect(comment).toHaveProperty('filePath'); expect(comment).toHaveProperty('rightFileStart'); expect(comment).toHaveProperty('rightFileEnd'); expect(comment).toHaveProperty('leftFileStart'); expect(comment).toHaveProperty('leftFileEnd'); }, 30000); test('should handle includeDeleted parameter', async () => { // Skip if integration tests should be skipped if (shouldSkipIntegrationTest() || !connection) { console.log('Skipping test due to missing connection'); return; } // Skip if repository name is not defined if (!repositoryName) { console.log('Skipping test due to missing repository name'); return; } const threads = await getPullRequestComments( connection, projectName, repositoryName, pullRequestId, { projectId: projectName, repositoryId: repositoryName, pullRequestId, includeDeleted: true, }, ); // We can only verify the call succeeds, as we can't guarantee deleted comments exist expect(threads).toBeDefined(); expect(Array.isArray(threads)).toBe(true); // If there are any threads, verify they have the new fields if (threads.length > 0) { const thread = threads[0]; if (thread.comments && thread.comments.length > 0) { const comment = thread.comments[0]; expect(comment).toHaveProperty('filePath'); expect(comment).toHaveProperty('rightFileStart'); expect(comment).toHaveProperty('rightFileEnd'); expect(comment).toHaveProperty('leftFileStart'); expect(comment).toHaveProperty('leftFileEnd'); } } }, 30000); // 30 second timeout for integration test }); ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { AzureDevOpsResourceNotFoundError, AzureDevOpsError, } from '../../../shared/errors'; import { TeamProject, WebApiTeam, } from 'azure-devops-node-api/interfaces/CoreInterfaces'; import { WorkItemField } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; // Type for work item type field with additional properties interface WorkItemTypeField extends WorkItemField { isRequired?: boolean; isIdentity?: boolean; isPicklist?: boolean; } /** * Options for getting project details */ export interface GetProjectDetailsOptions { projectId: string; includeProcess?: boolean; includeWorkItemTypes?: boolean; includeFields?: boolean; includeTeams?: boolean; expandTeamIdentity?: boolean; } /** * Process information with work item types */ interface ProcessInfo { id: string; name: string; description?: string; isDefault: boolean; type: string; workItemTypes?: WorkItemTypeInfo[]; hierarchyInfo?: { portfolioBacklogs?: { name: string; workItemTypes: string[]; }[]; requirementBacklog?: { name: string; workItemTypes: string[]; }; taskBacklog?: { name: string; workItemTypes: string[]; }; }; } /** * Work item type information with states and fields */ interface WorkItemTypeInfo { name: string; referenceName: string; description?: string; isDisabled: boolean; states?: { name: string; color?: string; stateCategory: string; }[]; fields?: { name: string; referenceName: string; type: string; required?: boolean; isIdentity?: boolean; isPicklist?: boolean; description?: string; }[]; } /** * Project details response */ interface ProjectDetails extends TeamProject { process?: ProcessInfo; teams?: WebApiTeam[]; } /** * Get detailed information about a project * * @param connection The Azure DevOps WebApi connection * @param options Options for getting project details * @returns The project details * @throws {AzureDevOpsResourceNotFoundError} If the project is not found */ export async function getProjectDetails( connection: WebApi, options: GetProjectDetailsOptions, ): Promise<ProjectDetails> { try { const { projectId, includeProcess = false, includeWorkItemTypes = false, includeFields = false, includeTeams = false, expandTeamIdentity = false, } = options; // Get the core API const coreApi = await connection.getCoreApi(); // Get the basic project information const project = await coreApi.getProject(projectId); if (!project) { throw new AzureDevOpsResourceNotFoundError( `Project '${projectId}' not found`, ); } // Initialize the result with the project information and ensure required properties const result: ProjectDetails = { ...project, // Ensure capabilities is always defined capabilities: project.capabilities || { versioncontrol: { sourceControlType: 'Git' }, processTemplate: { templateName: 'Unknown', templateTypeId: 'unknown' }, }, }; // If teams are requested, get them if (includeTeams) { const teams = await coreApi.getTeams(projectId, expandTeamIdentity); result.teams = teams; } // If process information is requested, get it if (includeProcess) { // Get the process template ID from the project capabilities const processTemplateId = project.capabilities?.processTemplate?.templateTypeId || 'unknown'; // Always create a process object, even if we don't have a template ID // In a real implementation, we would use the Process API // Since it's not directly available in the WebApi type, we'll simulate it // This is a simplified version for the implementation // In a real implementation, you would need to use the appropriate API // Create the process info object directly const processInfo: ProcessInfo = { id: processTemplateId, name: project.capabilities?.processTemplate?.templateName || 'Unknown', description: 'Process template for the project', isDefault: true, type: 'system', }; // If work item types are requested, get them if (includeWorkItemTypes) { // In a real implementation, we would get work item types from the API // For now, we'll use the work item tracking API to get basic types const workItemTrackingApi = await connection.getWorkItemTrackingApi(); const workItemTypes = await workItemTrackingApi.getWorkItemTypes(projectId); // Map the work item types to our format const processWorkItemTypes: WorkItemTypeInfo[] = workItemTypes.map( (wit) => { // Create the work item type info object const workItemTypeInfo: WorkItemTypeInfo = { name: wit.name || 'Unknown', referenceName: wit.referenceName || `System.Unknown.${Date.now()}`, description: wit.description, isDisabled: false, states: [ { name: 'New', stateCategory: 'Proposed' }, { name: 'Active', stateCategory: 'InProgress' }, { name: 'Resolved', stateCategory: 'InProgress' }, { name: 'Closed', stateCategory: 'Completed' }, ], }; // If fields are requested, don't add fields here - we'll add them after fetching from API return workItemTypeInfo; }, ); // If fields are requested, get the field definitions from the API if (includeFields) { try { // Instead of getting all fields and applying them to all work item types, // let's get the fields specific to each work item type for (const wit of processWorkItemTypes) { try { // Get fields specific to this work item type using the specialized method const typeSpecificFields = await workItemTrackingApi.getWorkItemTypeFieldsWithReferences( projectId, wit.name, ); // Map the fields to our format wit.fields = typeSpecificFields.map( (field: WorkItemTypeField) => ({ name: field.name || 'Unknown', referenceName: field.referenceName || 'Unknown', type: field.type?.toString().toLowerCase() || 'string', required: field.isRequired || false, isIdentity: field.isIdentity || false, isPicklist: field.isPicklist || false, description: field.description, }), ); } catch (typeFieldError) { console.error( `Error fetching fields for work item type ${wit.name}:`, typeFieldError, ); // Fallback to basic fields wit.fields = [ { name: 'Title', referenceName: 'System.Title', type: 'string', required: true, }, { name: 'Description', referenceName: 'System.Description', type: 'html', required: false, }, ]; } } } catch (fieldError) { console.error('Error in field processing:', fieldError); // Fallback to default fields if API call fails processWorkItemTypes.forEach((wit) => { wit.fields = [ { name: 'Title', referenceName: 'System.Title', type: 'string', required: true, }, { name: 'Description', referenceName: 'System.Description', type: 'html', required: false, }, ]; }); } } processInfo.workItemTypes = processWorkItemTypes; // Add hierarchy information if available // This is a simplified version - in a real implementation, you would // need to get the backlog configuration and map it to the work item types processInfo.hierarchyInfo = { portfolioBacklogs: [ { name: 'Epics', workItemTypes: processWorkItemTypes .filter( (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'epic', ) .map((wit: WorkItemTypeInfo) => wit.name), }, { name: 'Features', workItemTypes: processWorkItemTypes .filter( (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'feature', ) .map((wit: WorkItemTypeInfo) => wit.name), }, ], requirementBacklog: { name: 'Stories', workItemTypes: processWorkItemTypes .filter( (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'user story' || wit.name.toLowerCase() === 'bug', ) .map((wit: WorkItemTypeInfo) => wit.name), }, taskBacklog: { name: 'Tasks', workItemTypes: processWorkItemTypes .filter( (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'task', ) .map((wit: WorkItemTypeInfo) => wit.name), }, }; } // Always set the process on the result result.process = processInfo; } return result; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to get project details: ${error instanceof Error ? error.message : String(error)}`, ); } } ``` -------------------------------------------------------------------------------- /docs/authentication.md: -------------------------------------------------------------------------------- ```markdown # Authentication Guide for Azure DevOps MCP Server This guide provides detailed information about the authentication methods supported by the Azure DevOps MCP Server, including setup instructions, configuration examples, and troubleshooting tips. ## Supported Authentication Methods The Azure DevOps MCP Server supports three authentication methods: 1. **Personal Access Token (PAT)** - Simple token-based authentication 2. **Azure Identity (DefaultAzureCredential)** - Flexible authentication using the Azure Identity SDK 3. **Azure CLI** - Authentication using your Azure CLI login ## Method 1: Personal Access Token (PAT) Authentication PAT authentication is the simplest method and works well for personal use or testing. ### 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" - Name your token (e.g., "MCP Server Access") - Set an expiration date - Select the following scopes: - **Code**: Read & Write - **Work Items**: Read & Write - **Build**: Read & Execute - **Project and Team**: Read - **Graph**: Read - **Release**: Read & Execute - Click "Create" and copy the generated token 2. **Configure your `.env` file**: ``` AZURE_DEVOPS_AUTH_METHOD=pat AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_PAT=your-personal-access-token AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project ``` ### Security Considerations - PATs have an expiration date and will need to be renewed - Store your PAT securely and never commit it to source control - Consider using environment variables or a secrets manager in production - Scope your PAT to only the permissions needed for your use case ## Method 2: Azure Identity Authentication (DefaultAzureCredential) Azure Identity authentication uses the `DefaultAzureCredential` class from the `@azure/identity` package, which provides a simplified authentication experience by trying multiple credential types in sequence. ### How DefaultAzureCredential Works `DefaultAzureCredential` tries the following credential types in order: 1. Environment variables (EnvironmentCredential) 2. Managed Identity (ManagedIdentityCredential) 3. Azure CLI (AzureCliCredential) 4. Visual Studio Code (VisualStudioCodeCredential) 5. Azure PowerShell (AzurePowerShellCredential) 6. Interactive Browser (InteractiveBrowserCredential) - optional, disabled by default This makes it ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes. ### Setup Instructions 1. **Install the Azure Identity SDK**: 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 AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project ``` 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 ``` b. **For managed identities in Azure**: 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 ### Security Considerations - Use managed identities in Azure for improved security - For service principals, rotate client secrets regularly - Store credentials securely using Azure Key Vault or environment variables - Apply the principle of least privilege when assigning roles ## Method 3: Azure CLI Authentication Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/identity` package, which authenticates using the Azure CLI's logged-in account. ### 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 ``` 3. **Configure your `.env` file**: ``` AZURE_DEVOPS_AUTH_METHOD=azure-cli AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project ``` ### Security Considerations - Azure CLI authentication is best for local development - Ensure your Azure CLI session is kept secure - Log out when not in use: `az logout` ## Configuration Reference | Variable | Description | Required | Default | | ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------- | ---------------- | | `AZURE_DEVOPS_AUTH_METHOD` | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | 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 | - | | `LOG_LEVEL` | Logging level (debug, info, warn, error) | No | info | ## 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 3. **Organization access**: Verify your PAT has access to the organization specified in the URL - Error: `TF400813: Resource not found for anonymous request.` - Solution: Ensure your PAT has access to the specified organization ### 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 3. **Token acquisition errors**: Check network connectivity and Azure AD endpoint availability - Error: `ClientAuthError: Interaction required` - Solution: Check network connectivity or use a different credential type ### 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` 3. **Permission issues**: Check if your Azure CLI account has access to Azure DevOps - Error: `TF400813: The user is not authorized to access this resource` - Solution: Log in with an account that has access to Azure DevOps ## 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 4. **Monitor and audit authentication**: - Review Azure DevOps access logs - Set up alerts for suspicious activity ## Examples ### Example 1: Local Development with PAT ```bash # .env file AZURE_DEVOPS_AUTH_METHOD=pat AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_PAT=abcdefghijklmnopqrstuvwxyz0123456789 AZURE_DEVOPS_DEFAULT_PROJECT=MyProject ``` ### Example 2: Azure-hosted Application with Managed Identity ```bash # .env file AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_DEFAULT_PROJECT=MyProject ``` ### Example 3: CI/CD Pipeline with Service Principal ```bash # .env file AZURE_DEVOPS_AUTH_METHOD=azure-identity AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_DEFAULT_PROJECT=MyProject AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000 AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111 AZURE_CLIENT_SECRET=your-client-secret ``` ### Example 4: Local Development with Azure CLI ```bash # .env file AZURE_DEVOPS_AUTH_METHOD=azure-cli AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany AZURE_DEVOPS_DEFAULT_PROJECT=MyProject ``` ``` -------------------------------------------------------------------------------- /src/features/search/search-work-items/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import axios from 'axios'; import { searchWorkItems } from './feature'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, AzureDevOpsValidationError, AzureDevOpsPermissionError, } from '../../../shared/errors'; import { SearchWorkItemsOptions, WorkItemSearchResponse } from '../types'; // Mock axios jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>; // Mock @azure/identity jest.mock('@azure/identity', () => ({ DefaultAzureCredential: jest.fn().mockImplementation(() => ({ getToken: jest .fn() .mockResolvedValue({ token: 'mock-azure-identity-token' }), })), AzureCliCredential: jest.fn(), })); // Mock WebApi jest.mock('azure-devops-node-api'); const MockedWebApi = WebApi as jest.MockedClass<typeof WebApi>; describe('searchWorkItems', () => { let connection: WebApi; let options: SearchWorkItemsOptions; let mockResponse: WorkItemSearchResponse; beforeEach(() => { // Reset mocks jest.clearAllMocks(); // Mock environment variables process.env.AZURE_DEVOPS_AUTH_METHOD = 'pat'; process.env.AZURE_DEVOPS_PAT = 'mock-pat'; // Set up connection mock // Create a mock auth handler that implements IRequestHandler const mockAuthHandler = { prepareRequest: jest.fn(), canHandleAuthentication: jest.fn().mockReturnValue(true), handleAuthentication: jest.fn(), }; connection = new MockedWebApi( 'https://dev.azure.com/mock-org', mockAuthHandler, ); (connection as any).serverUrl = 'https://dev.azure.com/mock-org'; (connection.getCoreApi as jest.Mock).mockResolvedValue({ getProjects: jest.fn().mockResolvedValue([]), }); // Set up options options = { searchText: 'test query', projectId: 'mock-project', top: 50, skip: 0, includeFacets: true, }; // Set up mock response mockResponse = { count: 2, results: [ { project: { id: 'project-id-1', name: 'mock-project', }, fields: { 'system.id': '42', 'system.workitemtype': 'Bug', 'system.title': 'Test Bug', 'system.state': 'Active', 'system.assignedto': 'Test User', }, hits: [ { fieldReferenceName: 'system.title', highlights: ['Test <b>Bug</b>'], }, ], url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/42', }, { project: { id: 'project-id-1', name: 'mock-project', }, fields: { 'system.id': '43', 'system.workitemtype': 'Task', 'system.title': 'Test Task', 'system.state': 'New', 'system.assignedto': 'Test User', }, hits: [ { fieldReferenceName: 'system.title', highlights: ['Test <b>Task</b>'], }, ], url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/43', }, ], facets: { 'System.WorkItemType': [ { name: 'Bug', id: 'Bug', resultCount: 1, }, { name: 'Task', id: 'Task', resultCount: 1, }, ], }, }; // Mock axios response mockedAxios.post.mockResolvedValue({ data: mockResponse }); }); afterEach(() => { // Clean up environment variables delete process.env.AZURE_DEVOPS_AUTH_METHOD; delete process.env.AZURE_DEVOPS_PAT; }); it('should search work items with the correct parameters', async () => { // Act const result = await searchWorkItems(connection, options); // Assert expect(mockedAxios.post).toHaveBeenCalledWith( 'https://almsearch.dev.azure.com/mock-org/mock-project/_apis/search/workitemsearchresults?api-version=7.1', { searchText: 'test query', $skip: 0, $top: 50, filters: { 'System.TeamProject': ['mock-project'], }, includeFacets: true, }, expect.objectContaining({ headers: expect.objectContaining({ Authorization: expect.stringContaining('Basic'), 'Content-Type': 'application/json', }), }), ); expect(result).toEqual(mockResponse); }); it('should include filters when provided', async () => { // Arrange options.filters = { 'System.WorkItemType': ['Bug', 'Task'], 'System.State': ['Active'], }; // Act await searchWorkItems(connection, options); // Assert expect(mockedAxios.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ filters: { 'System.TeamProject': ['mock-project'], 'System.WorkItemType': ['Bug', 'Task'], 'System.State': ['Active'], }, }), expect.any(Object), ); }); it('should include orderBy when provided', async () => { // Arrange options.orderBy = [{ field: 'System.CreatedDate', sortOrder: 'ASC' }]; // Act await searchWorkItems(connection, options); // Assert expect(mockedAxios.post).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ $orderBy: [{ field: 'System.CreatedDate', sortOrder: 'ASC' }], }), expect.any(Object), ); }); it('should handle 404 errors correctly', async () => { // Arrange - Mock the implementation to throw the specific error mockedAxios.post.mockImplementation(() => { throw new AzureDevOpsResourceNotFoundError( 'Resource not found: Project not found', ); }); // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsResourceNotFoundError, ); }); it('should handle 400 errors correctly', async () => { // Arrange - Mock the implementation to throw the specific error mockedAxios.post.mockImplementation(() => { throw new AzureDevOpsValidationError('Invalid request: Invalid query'); }); // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsValidationError, ); }); it('should handle 401/403 errors correctly', async () => { // Arrange - Mock the implementation to throw the specific error mockedAxios.post.mockImplementation(() => { throw new AzureDevOpsPermissionError( 'Permission denied: Permission denied', ); }); // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsPermissionError, ); }); it('should handle other axios errors correctly', async () => { // Arrange - Mock the implementation to throw the specific error mockedAxios.post.mockImplementation(() => { throw new AzureDevOpsError( 'Azure DevOps API error: Internal server error', ); }); // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsError, ); }); it('should handle non-axios errors correctly', async () => { // Arrange mockedAxios.post.mockRejectedValue(new Error('Network error')); // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsError, ); }); it('should throw an error if organization cannot be extracted', async () => { // Arrange (connection as any).serverUrl = 'https://invalid-url'; // Act & Assert await expect(searchWorkItems(connection, options)).rejects.toThrow( AzureDevOpsValidationError, ); }); it('should use Azure Identity authentication when AZURE_DEVOPS_AUTH_METHOD is azure-identity', async () => { // Mock environment variables const originalEnv = process.env.AZURE_DEVOPS_AUTH_METHOD; process.env.AZURE_DEVOPS_AUTH_METHOD = 'azure-identity'; // Mock the WebApi connection const mockConnection = { serverUrl: 'https://dev.azure.com/testorg', getCoreApi: jest.fn().mockResolvedValue({ getProjects: jest.fn().mockResolvedValue([]), }), }; // Mock axios post const mockResponse = { data: { count: 0, results: [], }, }; (axios.post as jest.Mock).mockResolvedValueOnce(mockResponse); // Call the function await searchWorkItems(mockConnection as unknown as WebApi, { projectId: 'testproject', searchText: 'test query', }); // Verify the axios post was called with a Bearer token expect(axios.post).toHaveBeenCalledWith( expect.any(String), expect.any(Object), { headers: { Authorization: 'Bearer mock-azure-identity-token', 'Content-Type': 'application/json', }, }, ); // Cleanup process.env.AZURE_DEVOPS_AUTH_METHOD = originalEnv; }); test('should perform organization-wide work item search when projectId is not provided', async () => { // Arrange const mockSearchResponse = { data: { count: 2, results: [ { id: 1, fields: { 'System.Title': 'Test Bug 1', 'System.State': 'Active', 'System.WorkItemType': 'Bug', 'System.TeamProject': 'Project1', }, project: { name: 'Project1', id: 'project-id-1', }, }, { id: 2, fields: { 'System.Title': 'Test Bug 2', 'System.State': 'Active', 'System.WorkItemType': 'Bug', 'System.TeamProject': 'Project2', }, project: { name: 'Project2', id: 'project-id-2', }, }, ], }, }; mockedAxios.post.mockResolvedValueOnce(mockSearchResponse); // Act const result = await searchWorkItems(connection, { searchText: 'bug', }); // Assert expect(result).toBeDefined(); expect(result.count).toBe(2); expect(result.results).toHaveLength(2); expect(result.results[0].fields['System.TeamProject']).toBe('Project1'); expect(result.results[1].fields['System.TeamProject']).toBe('Project2'); expect(mockedAxios.post).toHaveBeenCalledTimes(1); expect(mockedAxios.post).toHaveBeenCalledWith( expect.stringContaining( 'https://almsearch.dev.azure.com/mock-org/_apis/search/workitemsearchresults', ), expect.not.objectContaining({ filters: expect.objectContaining({ 'System.TeamProject': expect.anything(), }), }), expect.any(Object), ); }); }); ``` -------------------------------------------------------------------------------- /src/features/search/search-code/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { searchCode } from './feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { SearchCodeOptions } from '../types'; describe('searchCode integration', () => { let connection: WebApi | null = null; let projectName: string; beforeAll(async () => { // Get a real connection using environment variables connection = await getTestConnection(); projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject'; }); test('should search code in a project', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test: No Azure DevOps connection available'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } const options: SearchCodeOptions = { searchText: 'function', projectId: projectName, top: 10, }; try { // Act - make an actual API call to Azure DevOps const result = await searchCode(connection, options); // Assert on the actual response expect(result).toBeDefined(); expect(typeof result.count).toBe('number'); expect(Array.isArray(result.results)).toBe(true); // Check structure of returned items (if any) if (result.results.length > 0) { const firstResult = result.results[0]; expect(firstResult.fileName).toBeDefined(); expect(firstResult.path).toBeDefined(); expect(firstResult.project).toBeDefined(); expect(firstResult.repository).toBeDefined(); if (firstResult.project) { expect(firstResult.project.name).toBe(projectName); } } } catch (error) { // Skip test if the code search extension is not installed if ( error instanceof Error && (error.message.includes('ms.vss-code-search is not installed') || error.message.includes('Resource not found') || error.message.includes('Failed to search code')) ) { console.log( 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', ); return; } throw error; } }); test('should include file content when requested', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test: No Azure DevOps connection available'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } const options: SearchCodeOptions = { searchText: 'function', projectId: projectName, top: 5, includeContent: true, }; try { // Act - make an actual API call to Azure DevOps const result = await searchCode(connection, options); // Assert on the actual response expect(result).toBeDefined(); // Check if content is included (if any results) if (result.results.length > 0) { // At least some results should have content // Note: Some files might fail to fetch content, so we don't expect all to have it const hasContent = result.results.some((r) => r.content !== undefined); expect(hasContent).toBe(true); } } catch (error) { // Skip test if the code search extension is not installed if ( error instanceof Error && (error.message.includes('ms.vss-code-search is not installed') || error.message.includes('Resource not found') || error.message.includes('Failed to search code')) ) { console.log( 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', ); return; } throw error; } }); test('should filter results when filters are provided', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test: No Azure DevOps connection available'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } try { // First get some results to find a repository name const initialOptions: SearchCodeOptions = { searchText: 'function', projectId: projectName, top: 1, }; const initialResult = await searchCode(connection, initialOptions); // Skip if no results found if (initialResult.results.length === 0) { console.log('Skipping filter test: No initial results found'); return; } // Use the repository from the first result for filtering const repoName = initialResult.results[0].repository.name; const filteredOptions: SearchCodeOptions = { searchText: 'function', projectId: projectName, filters: { Repository: [repoName], }, top: 5, }; // Act - make an actual API call to Azure DevOps with filters const result = await searchCode(connection, filteredOptions); // Assert on the actual response expect(result).toBeDefined(); // All results should be from the specified repository if (result.results.length > 0) { const allFromRepo = result.results.every( (r) => r.repository.name === repoName, ); expect(allFromRepo).toBe(true); } } catch (error) { // Skip test if the code search extension is not installed if ( error instanceof Error && (error.message.includes('ms.vss-code-search is not installed') || error.message.includes('Resource not found') || error.message.includes('Failed to search code')) ) { console.log( 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', ); return; } throw error; } }); test('should handle pagination', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test: No Azure DevOps connection available'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } try { // Get first page const firstPageOptions: SearchCodeOptions = { searchText: 'function', projectId: projectName, top: 2, skip: 0, }; const firstPageResult = await searchCode(connection, firstPageOptions); // Skip if not enough results for pagination test if (firstPageResult.count <= 2) { console.log('Skipping pagination test: Not enough results'); return; } // Get second page const secondPageOptions: SearchCodeOptions = { searchText: 'function', projectId: projectName, top: 2, skip: 2, }; const secondPageResult = await searchCode(connection, secondPageOptions); // Assert on pagination expect(secondPageResult).toBeDefined(); expect(secondPageResult.results.length).toBeGreaterThan(0); // First and second page should have different results if ( firstPageResult.results.length > 0 && secondPageResult.results.length > 0 ) { const firstPagePaths = firstPageResult.results.map((r) => r.path); const secondPagePaths = secondPageResult.results.map((r) => r.path); // Check if there's any overlap between pages const hasOverlap = firstPagePaths.some((path) => secondPagePaths.includes(path), ); expect(hasOverlap).toBe(false); } } catch (error) { // Skip test if the code search extension is not installed if ( error instanceof Error && (error.message.includes('ms.vss-code-search is not installed') || error.message.includes('Resource not found') || error.message.includes('Failed to search code')) ) { console.log( 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', ); return; } throw error; } }); test('should use default project when no projectId is provided', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test: No Azure DevOps connection available'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Store original environment variable const originalEnv = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; try { // Set the default project to the current project name for testing process.env.AZURE_DEVOPS_DEFAULT_PROJECT = projectName; // Search without specifying a project ID const options: SearchCodeOptions = { searchText: 'function', top: 5, }; // Act - make an actual API call to Azure DevOps const result = await searchCode(connection, options); // Assert on the actual response expect(result).toBeDefined(); expect(typeof result.count).toBe('number'); expect(Array.isArray(result.results)).toBe(true); // Check structure of returned items (if any) if (result.results.length > 0) { const firstResult = result.results[0]; expect(firstResult.fileName).toBeDefined(); expect(firstResult.path).toBeDefined(); expect(firstResult.project).toBeDefined(); expect(firstResult.repository).toBeDefined(); if (firstResult.project) { expect(firstResult.project.name).toBe(projectName); } } } catch (error) { // Skip test if the code search extension is not installed if ( error instanceof Error && (error.message.includes('ms.vss-code-search is not installed') || error.message.includes('Resource not found') || error.message.includes('Failed to search code')) ) { console.log( 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization', ); return; } throw error; } finally { // Restore original environment variable process.env.AZURE_DEVOPS_DEFAULT_PROJECT = originalEnv; } }); }); ``` -------------------------------------------------------------------------------- /src/features/search/types.ts: -------------------------------------------------------------------------------- ```typescript /** * Options for searching code in Azure DevOps repositories */ export interface SearchCodeOptions { searchText: string; projectId?: string; filters?: { Repository?: string[]; Path?: string[]; Branch?: string[]; CodeElement?: string[]; }; top?: number; skip?: number; includeSnippet?: boolean; includeContent?: boolean; } /** * Request body for the Azure DevOps Search API */ export interface CodeSearchRequest { searchText: string; $skip?: number; $top?: number; filters?: { Project?: string[]; Repository?: string[]; Path?: string[]; Branch?: string[]; CodeElement?: string[]; }; includeFacets?: boolean; includeSnippet?: boolean; } /** * Match information for search results */ export interface CodeSearchMatch { charOffset: number; length: number; } /** * Collection information for search results */ export interface CodeSearchCollection { name: string; } /** * Project information for search results */ export interface CodeSearchProject { name: string; id: string; } /** * Repository information for search results */ export interface CodeSearchRepository { name: string; id: string; type: string; } /** * Version information for search results */ export interface CodeSearchVersion { branchName: string; changeId: string; } /** * Individual code search result */ export interface CodeSearchResult { fileName: string; path: string; content?: string; // Added to store full file content matches: { content?: CodeSearchMatch[]; fileName?: CodeSearchMatch[]; }; collection: CodeSearchCollection; project: CodeSearchProject; repository: CodeSearchRepository; versions: CodeSearchVersion[]; contentId: string; } /** * Facet information for search results */ export interface CodeSearchFacet { name: string; id: string; resultCount: number; } /** * Response from the Azure DevOps Search API */ export interface CodeSearchResponse { count: number; results: CodeSearchResult[]; infoCode?: number; facets?: { Project?: CodeSearchFacet[]; Repository?: CodeSearchFacet[]; Path?: CodeSearchFacet[]; Branch?: CodeSearchFacet[]; CodeElement?: CodeSearchFacet[]; }; } /** * Options for searching wiki pages in Azure DevOps projects */ export interface SearchWikiOptions { /** * The text to search for within wiki pages */ searchText: string; /** * The ID or name of the project to search in * If not provided, search will be performed across the entire organization */ projectId?: string; /** * Optional filters to narrow search results */ filters?: { /** * Filter by project names. Useful for cross-project searches. */ Project?: string[]; }; /** * Number of results to return * @default 100 * @minimum 1 * @maximum 1000 */ top?: number; /** * Number of results to skip for pagination * @default 0 * @minimum 0 */ skip?: number; /** * Whether to include faceting in results * @default true */ includeFacets?: boolean; } /** * Request body for the Azure DevOps Wiki Search API */ export interface WikiSearchRequest { /** * The search text to find in wiki pages */ searchText: string; /** * Number of results to skip for pagination */ $skip?: number; /** * Number of results to return */ $top?: number; /** * Filters to be applied. Set to null if no filters are needed. */ filters?: { /** * Filter by project names */ Project?: string[]; }; /** * Options for sorting search results * If null, results are sorted by relevance */ $orderBy?: SortOption[]; /** * Whether to include faceting in the result * @default false */ includeFacets?: boolean; } /** * Sort option for search results */ export interface SortOption { /** * Field to sort by */ field: string; /** * Sort direction */ sortOrder: 'asc' | 'desc' | 'ASC' | 'DESC'; } /** * Defines the matched terms in the field of the wiki result */ export interface WikiHit { /** * Reference name of the highlighted field */ fieldReferenceName: string; /** * Matched/highlighted snippets of the field */ highlights: string[]; } /** * Defines the wiki result that matched a wiki search request */ export interface WikiResult { /** * Name of the result file */ fileName: string; /** * Path at which result file is present */ path: string; /** * Collection of the result file */ collection: { /** * Name of the collection */ name: string; }; /** * Project details of the wiki document */ project: { /** * ID of the project */ id: string; /** * Name of the project */ name: string; /** * Visibility of the project */ visibility?: string; }; /** * Wiki information for the result */ wiki: { /** * ID of the wiki */ id: string; /** * Mapped path for the wiki */ mappedPath: string; /** * Name of the wiki */ name: string; /** * Version for wiki */ version: string; }; /** * Content ID of the result file */ contentId: string; /** * Highlighted snippets of fields that match the search request * The list is sorted by relevance of the snippets */ hits: WikiHit[]; } /** * Defines a wiki search response item */ export interface WikiSearchResponse { /** * Total number of matched wiki documents */ count: number; /** * List of top matched wiki documents */ results: WikiResult[]; /** * Numeric code indicating additional information: * 0 - Ok * 1 - Account is being reindexed * 2 - Account indexing has not started * 3 - Invalid Request * ... and others as defined in the API */ infoCode?: number; /** * A dictionary storing an array of Filter objects against each facet */ facets?: { /** * Project facets for filtering */ Project?: CodeSearchFacet[]; }; } /** * Options for searching work items in Azure DevOps projects */ export interface SearchWorkItemsOptions { /** * The text to search for within work items */ searchText: string; /** * The ID or name of the project to search in * If not provided, search will be performed across the entire organization */ projectId?: string; /** * Optional filters to narrow search results */ filters?: { /** * Filter by project names. Useful for cross-project searches. */ 'System.TeamProject'?: string[]; /** * Filter by work item types (Bug, Task, User Story, etc.) */ 'System.WorkItemType'?: string[]; /** * Filter by work item states (New, Active, Closed, etc.) */ 'System.State'?: string[]; /** * Filter by assigned users */ 'System.AssignedTo'?: string[]; /** * Filter by area paths */ 'System.AreaPath'?: string[]; }; /** * Number of results to return * @default 100 * @minimum 1 * @maximum 1000 */ top?: number; /** * Number of results to skip for pagination * @default 0 * @minimum 0 */ skip?: number; /** * Whether to include faceting in results * @default true */ includeFacets?: boolean; /** * Options for sorting search results * If null, results are sorted by relevance */ orderBy?: SortOption[]; } /** * Request body for the Azure DevOps Work Item Search API */ export interface WorkItemSearchRequest { /** * The search text to find in work items */ searchText: string; /** * Number of results to skip for pagination */ $skip?: number; /** * Number of results to return */ $top?: number; /** * Filters to be applied. Set to null if no filters are needed. */ filters?: { 'System.TeamProject'?: string[]; 'System.WorkItemType'?: string[]; 'System.State'?: string[]; 'System.AssignedTo'?: string[]; 'System.AreaPath'?: string[]; }; /** * Options for sorting search results * If null, results are sorted by relevance */ $orderBy?: SortOption[]; /** * Whether to include faceting in the result * @default false */ includeFacets?: boolean; } /** * Defines the matched terms in the field of the work item result */ export interface WorkItemHit { /** * Reference name of the highlighted field */ fieldReferenceName: string; /** * Matched/highlighted snippets of the field */ highlights: string[]; } /** * Defines the work item result that matched a work item search request */ export interface WorkItemResult { /** * Project details of the work item */ project: { /** * ID of the project */ id: string; /** * Name of the project */ name: string; }; /** * A standard set of work item fields and their values */ fields: { /** * ID of the work item */ 'system.id': string; /** * Type of the work item (Bug, Task, User Story, etc.) */ 'system.workitemtype': string; /** * Title of the work item */ 'system.title': string; /** * User assigned to the work item */ 'system.assignedto'?: string; /** * Current state of the work item */ 'system.state'?: string; /** * Tags associated with the work item */ 'system.tags'?: string; /** * Revision number of the work item */ 'system.rev'?: string; /** * Creation date of the work item */ 'system.createddate'?: string; /** * Last modified date of the work item */ 'system.changeddate'?: string; /** * Other fields may be included based on the work item type */ [key: string]: string | number | boolean | null | undefined; }; /** * Highlighted snippets of fields that match the search request * The list is sorted by relevance of the snippets */ hits: WorkItemHit[]; /** * URL to the work item */ url: string; } /** * Defines a work item search response item */ export interface WorkItemSearchResponse { /** * Total number of matched work items */ count: number; /** * List of top matched work items */ results: WorkItemResult[]; /** * Numeric code indicating additional information: * 0 - Ok * 1 - Account is being reindexed * 2 - Account indexing has not started * 3 - Invalid Request * ... and others as defined in the API */ infoCode?: number; /** * A dictionary storing an array of Filter objects against each facet */ facets?: { 'System.TeamProject'?: CodeSearchFacet[]; 'System.WorkItemType'?: CodeSearchFacet[]; 'System.State'?: CodeSearchFacet[]; 'System.AssignedTo'?: CodeSearchFacet[]; 'System.AreaPath'?: CodeSearchFacet[]; }; } ``` -------------------------------------------------------------------------------- /setup_env.sh: -------------------------------------------------------------------------------- ```bash #!/bin/bash # Global variable to track if an error has occurred ERROR_OCCURRED=0 # Function to handle errors without exiting the shell when sourced handle_error() { local message=$1 local reset_colors="\033[0m" echo -e "\033[0;31m$message$reset_colors" # Set the error flag ERROR_OCCURRED=1 # If script is being sourced (. or source) if [[ "${BASH_SOURCE[0]}" != "${0}" ]] || [[ -n "$ZSH_VERSION" && "$ZSH_EVAL_CONTEXT" == *:file:* ]]; then echo "Script terminated with error. Returning to shell." # Reset colors to ensure shell isn't affected echo -e "$reset_colors" # The return will be caught by the caller return 1 else # If script is being executed directly exit 1 fi } # Function to check if we should continue after potential error points should_continue() { if [ $ERROR_OCCURRED -eq 1 ]; then # Reset colors to ensure shell isn't affected echo -e "\033[0m" return 1 fi return 0 } # Ensure script is running with a compatible shell if [ -z "$BASH_VERSION" ] && [ -z "$ZSH_VERSION" ]; then handle_error "This script requires bash or zsh to run. Please run it with: bash $(basename "$0") or zsh $(basename "$0")" return 1 2>/dev/null || exit 1 fi # Set shell options for compatibility if [ -n "$ZSH_VERSION" ]; then # ZSH specific settings setopt SH_WORD_SPLIT setopt KSH_ARRAYS fi # Colors for better output - ensure they're properly reset after use GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' NC='\033[0m' # No Color echo -e "${GREEN}Azure DevOps MCP Server - Environment Setup${NC}" echo "This script will help you set up your .env file with Azure DevOps credentials." echo # Clean up any existing create_pat.json file if [ -f "create_pat.json" ]; then echo -e "${YELLOW}Cleaning up existing create_pat.json file...${NC}" rm -f create_pat.json fi # Check if Azure CLI is installed if ! command -v az &> /dev/null; then handle_error "Error: Azure CLI is not installed.\nPlease install Azure CLI first: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli" return 1 2>/dev/null || exit 1 fi should_continue || return 1 2>/dev/null || exit 1 # Check if Azure DevOps extension is installed echo -e "${YELLOW}Checking for Azure DevOps extension...${NC}" az devops &> /dev/null if [ $? -ne 0 ]; then echo "Azure DevOps extension not found. Installing..." az extension add --name azure-devops if [ $? -ne 0 ]; then handle_error "Failed to install Azure DevOps extension." return 1 2>/dev/null || exit 1 else echo -e "${GREEN}Azure DevOps extension installed successfully.${NC}" fi else echo "Azure DevOps extension is already installed." fi should_continue || return 1 2>/dev/null || exit 1 # Check if jq is installed if ! command -v jq &> /dev/null; then handle_error "Error: jq is not installed.\nPlease install jq first. On Ubuntu/Debian: sudo apt-get install jq\nOn macOS: brew install jq" return 1 2>/dev/null || exit 1 fi should_continue || return 1 2>/dev/null || exit 1 # Check if already logged in echo -e "\n${YELLOW}Step 1: Checking Azure CLI authentication...${NC}" if ! az account show &> /dev/null; then echo "Not logged in. Initiating login..." az login --allow-no-subscriptions if [ $? -ne 0 ]; then handle_error "Failed to login to Azure CLI." return 1 2>/dev/null || exit 1 fi else echo -e "${GREEN}Already logged in to Azure CLI.${NC}" fi should_continue || return 1 2>/dev/null || exit 1 # Get Azure DevOps Organizations using REST API echo -e "\n${YELLOW}Step 2: Fetching your Azure DevOps organizations...${NC}" echo "This may take a moment..." # First get the user profile echo "Getting user profile..." profile_response=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798" 2>&1) profile_status=$? if [ $profile_status -ne 0 ]; then echo -e "${RED}Error: Failed to get user profile${NC}" echo -e "${RED}Status code: $profile_status${NC}" echo -e "${RED}Error response:${NC}" echo "$profile_response" echo echo "Manually provide your organization name instead." read -p "Enter your Azure DevOps organization name: " org_name else echo "Profile API response:" echo "$profile_response" echo public_alias=$(echo "$profile_response" | jq -r '.publicAlias') if [ "$public_alias" = "null" ] || [ -z "$public_alias" ]; then echo -e "${RED}Failed to extract publicAlias from response.${NC}" echo "Full response was:" echo "$profile_response" echo echo "Manually provide your organization name instead." read -p "Enter your Azure DevOps organization name: " org_name else # Get organizations using the publicAlias echo "Fetching organizations..." orgs_result=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/accounts?memberId=$public_alias&api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798") # Extract organization names from the response using jq orgs=$(echo "$orgs_result" | jq -r '.value[].accountName') if [ -z "$orgs" ]; then echo -e "${RED}No organizations found.${NC}" echo "Manually provide your organization name instead." read -p "Enter your Azure DevOps organization name: " org_name else # Display organizations for selection echo -e "\nYour Azure DevOps organizations:" i=1 OLDIFS=$IFS IFS=$'\n' # Create array in a shell-agnostic way orgs_array=() while IFS= read -r line; do [ -n "$line" ] && orgs_array+=("$line") done <<< "$orgs" IFS=$OLDIFS # Check if array is empty if [ ${#orgs_array[@]} -eq 0 ]; then echo -e "${RED}Failed to parse organizations list.${NC}" echo "Manually provide your organization name instead." read -p "Enter your Azure DevOps organization name: " org_name else # Display organizations with explicit indexing for ((idx=0; idx<${#orgs_array[@]}; idx++)); do echo "$((idx+1)) ${orgs_array[$idx]}" done # Prompt for selection read -p "Select an organization (1-${#orgs_array[@]}): " org_selection if [[ "$org_selection" =~ ^[0-9]+$ ]] && [ "$org_selection" -ge 1 ] && [ "$org_selection" -le "${#orgs_array[@]}" ]; then org_name=${orgs_array[$((org_selection-1))]} else handle_error "Invalid selection. Please run the script again." return 1 2>/dev/null || exit 1 fi fi fi fi fi should_continue || return 1 2>/dev/null || exit 1 org_url="https://dev.azure.com/$org_name" echo -e "${GREEN}Using organization URL: $org_url${NC}" # Get Default Project (Optional) echo -e "\n${YELLOW}Step 3: Would you like to set a default project? (y/n)${NC}" read -p "Select option: " set_default_project default_project="" if [[ "$set_default_project" = "y" || "$set_default_project" = "Y" ]]; then # Configure az devops to use the selected organization az devops configure --defaults organization=$org_url # List projects echo "Fetching projects from $org_name..." projects=$(az devops project list --query "value[].name" -o tsv) if [ $? -ne 0 ] || [ -z "$projects" ]; then echo -e "${YELLOW}No projects found or unable to list projects.${NC}" read -p "Enter a default project name (leave blank to skip): " default_project else # Display projects for selection echo -e "\nAvailable projects in $org_name:" OLDIFS=$IFS IFS=$'\n' # Create array in a shell-agnostic way projects_array=() while IFS= read -r line; do [ -n "$line" ] && projects_array+=("$line") done <<< "$projects" IFS=$OLDIFS # Check if array is empty if [ ${#projects_array[@]} -eq 0 ]; then echo -e "${YELLOW}Failed to parse projects list.${NC}" read -p "Enter a default project name (leave blank to skip): " default_project else # Display projects with explicit indexing for ((idx=0; idx<${#projects_array[@]}; idx++)); do echo "$((idx+1)) ${projects_array[$idx]}" done echo "$((${#projects_array[@]}+1)) Skip setting a default project" # Prompt for selection read -p "Select a default project (1-$((${#projects_array[@]}+1))): " project_selection if [[ "$project_selection" =~ ^[0-9]+$ ]] && [ "$project_selection" -ge 1 ] && [ "$project_selection" -lt "$((${#projects_array[@]}+1))" ]; then default_project=${projects_array[$((project_selection-1))]} echo -e "${GREEN}Using default project: $default_project${NC}" else echo "No default project selected." fi fi fi fi # Create .env file echo -e "\n${YELLOW}Step 5: Creating .env file...${NC}" cat > .env << EOF # Azure DevOps MCP Server - Environment Variables # Azure DevOps Organization Name (selected from your available organizations) AZURE_DEVOPS_ORG=$org_name # Azure DevOps Organization URL (required) AZURE_DEVOPS_ORG_URL=$org_url AZURE_DEVOPS_AUTH_METHOD=azure-identity EOF # Add default project if specified if [ ! -z "$default_project" ]; then cat >> .env << EOF # Default Project to use when not specified AZURE_DEVOPS_DEFAULT_PROJECT=$default_project EOF else cat >> .env << EOF # Default Project to use when not specified (optional) # AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project EOF fi # Add remaining configuration cat >> .env << EOF # API Version to use (optional, defaults to latest) # AZURE_DEVOPS_API_VERSION=6.0 # Server Configuration PORT=3000 HOST=localhost # Logging Level (debug, info, warn, error) LOG_LEVEL=info EOF echo -e "\n${GREEN}Environment setup completed successfully!${NC}" echo "Your .env file has been created with the following configuration:" echo "- Organization: $org_name" echo "- Organization URL: $org_url" if [ ! -z "$default_project" ]; then echo "- Default Project: $default_project" fi echo "- PAT: Created with expanded scopes for full integration" echo echo "You can now run your Azure DevOps MCP Server with:" echo " npm run dev" echo echo "You can also run integration tests with:" echo " npm run test:integration" # At the end of the script, ensure colors are reset echo -e "${NC}" ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/feature.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { IGitApi } from 'azure-devops-node-api/GitApi'; import { GitVersionType, VersionControlRecursionType, GitItem, GitObjectType, } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { minimatch } from 'minimatch'; import { AzureDevOpsError } from '../../../shared/errors'; import { GetAllRepositoriesTreeOptions, AllRepositoriesTreeResponse, RepositoryTreeResponse, RepositoryTreeItem, GitRepository, } from '../types'; /** * Get tree view of files/directories across multiple repositories * * @param connection The Azure DevOps WebApi connection * @param options Options for getting repository tree * @returns Tree structure for each repository */ export async function getAllRepositoriesTree( connection: WebApi, options: GetAllRepositoriesTreeOptions, ): Promise<AllRepositoriesTreeResponse> { try { const gitApi = await connection.getGitApi(); let repositories: GitRepository[] = []; // Get all repositories in the project repositories = await gitApi.getRepositories(options.projectId); // Filter repositories by name pattern if specified if (options.repositoryPattern) { repositories = repositories.filter((repo) => minimatch(repo.name || '', options.repositoryPattern || '*'), ); } // Initialize results array const results: RepositoryTreeResponse[] = []; // Process each repository for (const repo of repositories) { try { // Get default branch ref const defaultBranch = repo.defaultBranch; if (!defaultBranch) { // Skip repositories with no default branch results.push({ name: repo.name || 'Unknown', tree: [], stats: { directories: 0, files: 0 }, error: 'No default branch found', }); continue; } // Clean the branch name (remove refs/heads/ prefix) const branchRef = defaultBranch.replace('refs/heads/', ''); // Initialize tree items array and counters const treeItems: RepositoryTreeItem[] = []; const stats = { directories: 0, files: 0 }; // Determine the recursion level and processing approach const depth = options.depth !== undefined ? options.depth : 0; // Default to 0 (max depth) if (depth === 0) { // For max depth (0), use server-side recursion for better performance const allItems = await gitApi.getItems( repo.id || '', options.projectId, '/', VersionControlRecursionType.Full, // Use full recursion true, false, false, false, { version: branchRef, versionType: GitVersionType.Branch, }, ); // Filter out the root item itself and bad items const itemsToProcess = allItems.filter( (item) => item.path !== '/' && item.gitObjectType !== GitObjectType.Bad, ); // Process all items at once (they're already retrieved recursively) processItemsNonRecursive( itemsToProcess, treeItems, stats, options.pattern, ); } else { // For limited depth, use the regular recursive approach // Get items at the root level const rootItems = await gitApi.getItems( repo.id || '', options.projectId, '/', VersionControlRecursionType.OneLevel, true, false, false, false, { version: branchRef, versionType: GitVersionType.Branch, }, ); // Filter out the root item itself and bad items const itemsToProcess = rootItems.filter( (item) => item.path !== '/' && item.gitObjectType !== GitObjectType.Bad, ); // Process the root items and their children (up to specified depth) await processItems( gitApi, repo.id || '', options.projectId, itemsToProcess, branchRef, treeItems, stats, 1, depth, options.pattern, ); } // Add repository tree to results results.push({ name: repo.name || 'Unknown', tree: treeItems, stats, }); } catch (repoError) { // Handle errors for individual repositories results.push({ name: repo.name || 'Unknown', tree: [], stats: { directories: 0, files: 0 }, error: `Error processing repository: ${repoError instanceof Error ? repoError.message : String(repoError)}`, }); } } return { repositories: results }; } catch (error) { if (error instanceof AzureDevOpsError) { throw error; } throw new Error( `Failed to get repository tree: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Process items non-recursively when they're already retrieved with VersionControlRecursionType.Full */ function processItemsNonRecursive( items: GitItem[], result: RepositoryTreeItem[], stats: { directories: number; files: number }, pattern?: string, ): void { // Sort items (folders first, then by path) const sortedItems = [...items].sort((a, b) => { if (a.isFolder === b.isFolder) { return (a.path || '').localeCompare(b.path || ''); } return a.isFolder ? -1 : 1; }); for (const item of sortedItems) { const name = item.path?.split('/').pop() || ''; const path = item.path || ''; const isFolder = !!item.isFolder; // Skip the root folder if (path === '/') { continue; } // Calculate level from path segments // Remove leading '/' then count segments // For paths like: // /README.md -> ["README.md"] -> length 1 -> level 1 // /src/index.ts -> ["src", "index.ts"] -> length 2 -> level 2 // /src/utils/helper.ts -> ["src", "utils", "helper.ts"] -> length 3 -> level 3 const pathSegments = path.replace(/^\//, '').split('/'); const level = pathSegments.length; // Filter files based on pattern (if specified) if (!isFolder && pattern && !minimatch(name, pattern)) { continue; } // Add item to results result.push({ name, path, isFolder, level, }); // Update counters if (isFolder) { stats.directories++; } else { stats.files++; } } } /** * Process items recursively up to the specified depth */ async function processItems( gitApi: IGitApi, repoId: string, projectId: string, items: GitItem[], branchRef: string, result: RepositoryTreeItem[], stats: { directories: number; files: number }, currentDepth: number, maxDepth: number, pattern?: string, ): Promise<void> { // Sort items (directories first, then files) const sortedItems = [...items].sort((a, b) => { if (a.isFolder === b.isFolder) { return (a.path || '').localeCompare(b.path || ''); } return a.isFolder ? -1 : 1; }); for (const item of sortedItems) { const name = item.path?.split('/').pop() || ''; const path = item.path || ''; const isFolder = !!item.isFolder; // Filter files based on pattern (if specified) if (!isFolder && pattern && !minimatch(name, pattern)) { continue; } // Add item to results result.push({ name, path, isFolder, level: currentDepth, }); // Update counters if (isFolder) { stats.directories++; } else { stats.files++; } // Recursively process folders if not yet at max depth if (isFolder && currentDepth < maxDepth) { try { const childItems = await gitApi.getItems( repoId, projectId, path, VersionControlRecursionType.OneLevel, true, false, false, false, { version: branchRef, versionType: GitVersionType.Branch, }, ); // Filter out the parent folder itself and bad items const itemsToProcess = childItems.filter( (child: GitItem) => child.path !== path && child.gitObjectType !== GitObjectType.Bad, ); // Process child items await processItems( gitApi, repoId, projectId, itemsToProcess, branchRef, result, stats, currentDepth + 1, maxDepth, pattern, ); } catch (error) { // Ignore errors in child items and continue with siblings console.error(`Error processing folder ${path}: ${error}`); } } } } /** * Convert the tree items to a formatted ASCII string representation * * @param repoName Repository name * @param items Tree items * @param stats Statistics about files and directories * @returns Formatted ASCII string */ export function formatRepositoryTree( repoName: string, items: RepositoryTreeItem[], stats: { directories: number; files: number }, error?: string, ): string { let output = `${repoName}/\n`; if (error) { output += ` (${error})\n`; } else if (items.length === 0) { output += ' (Repository is empty or default branch not found)\n'; } else { // Sort items by path to ensure proper sequence const sortedItems = [...items].sort((a, b) => { // Sort by level first if (a.level !== b.level) { return a.level - b.level; } // Then folders before files if (a.isFolder !== b.isFolder) { return a.isFolder ? -1 : 1; } // Then alphabetically return a.path.localeCompare(b.path); }); // Create a structured tree representation const tree = createTreeStructure(sortedItems); // Format the tree starting from the root output += formatTree(tree, ' '); } // Add summary line output += `${stats.directories} directories, ${stats.files} files\n`; return output; } /** * Create a structured tree from the flat list of items */ function createTreeStructure(items: RepositoryTreeItem[]): TreeNode { const root: TreeNode = { name: '', path: '', isFolder: true, children: [], }; // Map to track all nodes by path const nodeMap: Record<string, TreeNode> = { '': root }; // First create all nodes for (const item of items) { nodeMap[item.path] = { name: item.name, path: item.path, isFolder: item.isFolder, children: [], }; } // Then build the hierarchy for (const item of items) { if (item.path === '/') continue; const node = nodeMap[item.path]; const lastSlashIndex = item.path.lastIndexOf('/'); // For root level items, the parent path is empty const parentPath = lastSlashIndex <= 0 ? '' : item.path.substring(0, lastSlashIndex); // Get parent node (defaults to root if parent not found) const parent = nodeMap[parentPath] || root; // Add this node as a child of its parent parent.children.push(node); } return root; } /** * Format a tree structure into an ASCII tree representation */ function formatTree(node: TreeNode, indent: string): string { if (!node.children.length) return ''; let output = ''; // Sort the children: folders first, then alphabetically const children = [...node.children].sort((a, b) => { if (a.isFolder !== b.isFolder) { return a.isFolder ? -1 : 1; } return a.name.localeCompare(b.name); }); // Format each child node for (let i = 0; i < children.length; i++) { const child = children[i]; const isLast = i === children.length - 1; const connector = isLast ? '`-- ' : '|-- '; const childIndent = isLast ? ' ' : '| '; // Add the node itself const suffix = child.isFolder ? '/' : ''; output += `${indent}${connector}${child.name}${suffix}\n`; // Recursively add its children if (child.children.length > 0) { output += formatTree(child, indent + childIndent); } } return output; } /** * Tree node interface for hierarchical representation */ interface TreeNode { name: string; path: string; isFolder: boolean; children: TreeNode[]; } ``` -------------------------------------------------------------------------------- /src/features/pull-requests/get-pull-request-comments/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { getPullRequestComments } from './feature'; import { GitPullRequestCommentThread } from 'azure-devops-node-api/interfaces/GitInterfaces'; describe('getPullRequestComments', () => { afterEach(() => { jest.resetAllMocks(); }); test('should return pull request comment threads with file path and line number', async () => { // Mock data for a comment thread const mockCommentThreads: GitPullRequestCommentThread[] = [ { id: 1, status: 1, // Active threadContext: { filePath: '/src/app.ts', rightFileStart: { line: 10, offset: 5, }, rightFileEnd: { line: 10, offset: 15, }, }, comments: [ { id: 100, content: 'This code needs refactoring', commentType: 1, // CodeChange author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }, { id: 101, parentCommentId: 100, content: 'I agree, will update', commentType: 1, // CodeChange author: { displayName: 'Another User', id: 'another-user-id', }, publishedDate: new Date(), }, ], }, ]; // Setup mock connection const mockGitApi = { getThreads: jest.fn().mockResolvedValue(mockCommentThreads), getPullRequestThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, }; const result = await getPullRequestComments( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results expect(result).toHaveLength(1); expect(result[0].comments).toHaveLength(2); // Verify file path and line number are added to each comment result[0].comments?.forEach((comment) => { expect(comment).toHaveProperty('filePath', '/src/app.ts'); expect(comment).toHaveProperty('rightFileStart', { line: 10, offset: 5 }); expect(comment).toHaveProperty('rightFileEnd', { line: 10, offset: 15 }); expect(comment).toHaveProperty('leftFileStart', undefined); expect(comment).toHaveProperty('leftFileEnd', undefined); }); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1); expect(mockGitApi.getThreads).toHaveBeenCalledWith( repositoryId, pullRequestId, projectId, undefined, undefined, ); expect(mockGitApi.getPullRequestThread).not.toHaveBeenCalled(); }); test('should handle comments without thread context', async () => { // Mock data for a comment thread without thread context const mockCommentThreads: GitPullRequestCommentThread[] = [ { id: 1, status: 1, // Active comments: [ { id: 100, content: 'General comment', commentType: 1, author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }, ], }, ]; // Setup mock connection const mockGitApi = { getThreads: jest.fn().mockResolvedValue(mockCommentThreads), getPullRequestThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; const result = await getPullRequestComments( mockConnection as WebApi, 'test-project', 'test-repo', 123, { projectId: 'test-project', repositoryId: 'test-repo', pullRequestId: 123, }, ); // Verify results expect(result).toHaveLength(1); expect(result[0].comments).toHaveLength(1); expect(result[0].status).toBe('active'); // Verify file path and line number are null for comments without thread context const comment = result[0].comments![0]; expect(comment).toHaveProperty('filePath', undefined); expect(comment).toHaveProperty('rightFileStart', undefined); expect(comment).toHaveProperty('rightFileEnd', undefined); expect(comment).toHaveProperty('leftFileStart', undefined); expect(comment).toHaveProperty('leftFileEnd', undefined); expect(comment).toHaveProperty('commentType', 'text'); }); test('should use leftFileStart when rightFileStart is not available', async () => { // Mock data for a comment thread with only leftFileStart const mockCommentThreads: GitPullRequestCommentThread[] = [ { id: 1, status: 1, threadContext: { filePath: '/src/app.ts', leftFileStart: { line: 5, offset: 1, }, }, comments: [ { id: 100, content: 'Comment on deleted line', commentType: 1, author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }, ], }, ]; // Setup mock connection const mockGitApi = { getThreads: jest.fn().mockResolvedValue(mockCommentThreads), getPullRequestThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; const result = await getPullRequestComments( mockConnection as WebApi, 'test-project', 'test-repo', 123, { projectId: 'test-project', repositoryId: 'test-repo', pullRequestId: 123, }, ); // Verify results expect(result).toHaveLength(1); expect(result[0].comments).toHaveLength(1); // Verify rightFileStart is undefined, leftFileStart is present const comment = result[0].comments![0]; expect(comment).toHaveProperty('filePath', '/src/app.ts'); expect(comment).toHaveProperty('leftFileStart', { line: 5, offset: 1 }); expect(comment).toHaveProperty('rightFileStart', undefined); expect(comment).toHaveProperty('leftFileEnd', undefined); expect(comment).toHaveProperty('rightFileEnd', undefined); }); test('should return a specific comment thread when threadId is provided', async () => { // Mock data for a specific comment thread const threadId = 42; const mockCommentThread: GitPullRequestCommentThread = { id: threadId, status: 1, // Active threadContext: { filePath: '/src/utils.ts', rightFileStart: { line: 15, offset: 1, }, }, comments: [ { id: 100, content: 'Specific comment', commentType: 1, // CodeChange author: { displayName: 'Test User', id: 'test-user-id', }, publishedDate: new Date(), }, ], }; // Setup mock connection const mockGitApi = { getThreads: jest.fn(), getPullRequestThread: jest.fn().mockResolvedValue(mockCommentThread), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, threadId, }; const result = await getPullRequestComments( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results expect(result).toHaveLength(1); expect(result[0].id).toBe(threadId); expect(result[0].comments).toHaveLength(1); // Verify file path and line number are added const comment = result[0].comments![0]; expect(comment).toHaveProperty('filePath', '/src/utils.ts'); expect(comment).toHaveProperty('rightFileStart', { line: 15, offset: 1 }); expect(comment).toHaveProperty('leftFileStart', undefined); expect(comment).toHaveProperty('leftFileEnd', undefined); expect(comment).toHaveProperty('rightFileEnd', undefined); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.getPullRequestThread).toHaveBeenCalledTimes(1); expect(mockGitApi.getPullRequestThread).toHaveBeenCalledWith( repositoryId, pullRequestId, threadId, projectId, ); expect(mockGitApi.getThreads).not.toHaveBeenCalled(); }); test('should handle pagination when top parameter is provided', async () => { // Mock data for multiple comment threads const mockCommentThreads: GitPullRequestCommentThread[] = [ { id: 1, status: 1, threadContext: { filePath: '/src/file1.ts', rightFileStart: { line: 1, offset: 1 }, }, comments: [{ id: 100, content: 'Comment 1' }], }, { id: 2, status: 1, threadContext: { filePath: '/src/file2.ts', rightFileStart: { line: 2, offset: 1 }, }, comments: [{ id: 101, content: 'Comment 2' }], }, { id: 3, status: 1, threadContext: { filePath: '/src/file3.ts', rightFileStart: { line: 3, offset: 1 }, }, comments: [{ id: 102, content: 'Comment 3' }], }, ]; // Setup mock connection const mockGitApi = { getThreads: jest.fn().mockResolvedValue(mockCommentThreads), getPullRequestThread: jest.fn(), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters and top=2 const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, top: 2, }; const result = await getPullRequestComments( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ); // Verify results (should only include first 2 threads) expect(result).toHaveLength(2); expect(result).toEqual( mockCommentThreads.slice(0, 2).map((thread) => ({ ...thread, status: 'active', // Transform enum to string comments: thread.comments?.map((comment) => ({ ...comment, commentType: undefined, // Will be undefined since mock doesn't have commentType filePath: thread.threadContext?.filePath, rightFileStart: thread.threadContext?.rightFileStart ?? undefined, rightFileEnd: thread.threadContext?.rightFileEnd ?? undefined, leftFileStart: thread.threadContext?.leftFileStart ?? undefined, leftFileEnd: thread.threadContext?.leftFileEnd ?? undefined, })), })), ); expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1); expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1); expect(result[0].comments![0]).toHaveProperty('rightFileStart', { line: 1, offset: 1, }); expect(result[1].comments![0]).toHaveProperty('rightFileStart', { line: 2, offset: 1, }); }); test('should handle error when API call fails', async () => { // Setup mock connection with error const errorMessage = 'API error'; const mockGitApi = { getThreads: jest.fn().mockRejectedValue(new Error(errorMessage)), }; const mockConnection: any = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), }; // Call the function with test parameters const projectId = 'test-project'; const repositoryId = 'test-repo'; const pullRequestId = 123; const options = { projectId, repositoryId, pullRequestId, }; // Verify error handling await expect( getPullRequestComments( mockConnection as WebApi, projectId, repositoryId, pullRequestId, options, ), ).rejects.toThrow(`Failed to get pull request comments: ${errorMessage}`); }); }); ``` -------------------------------------------------------------------------------- /src/features/repositories/get-all-repositories-tree/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { GitObjectType, VersionControlRecursionType, } from 'azure-devops-node-api/interfaces/GitInterfaces'; import { getAllRepositoriesTree, formatRepositoryTree } from './feature'; import { RepositoryTreeItem } from '../types'; // Mock the Azure DevOps API jest.mock('azure-devops-node-api'); describe('getAllRepositoriesTree', () => { // Sample repositories const mockRepos = [ { id: 'repo1-id', name: 'repo1', defaultBranch: 'refs/heads/main', }, { id: 'repo2-id', name: 'repo2', defaultBranch: 'refs/heads/master', }, { id: 'repo3-id', name: 'repo3-api', defaultBranch: null, // No default branch }, ]; // Sample files/folders for repo1 at root level const mockRepo1RootItems = [ { path: '/', gitObjectType: GitObjectType.Tree, }, { path: '/README.md', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src', isFolder: true, gitObjectType: GitObjectType.Tree, }, { path: '/package.json', isFolder: false, gitObjectType: GitObjectType.Blob, }, ]; // Sample files/folders for repo1 - src folder const mockRepo1SrcItems = [ { path: '/src', isFolder: true, gitObjectType: GitObjectType.Tree, }, { path: '/src/index.ts', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src/utils', isFolder: true, gitObjectType: GitObjectType.Tree, }, ]; // Sample files/folders for repo1 with unlimited depth (what server would return for Full recursion) const mockRepo1FullRecursionItems = [ { path: '/', gitObjectType: GitObjectType.Tree, }, { path: '/README.md', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src', isFolder: true, gitObjectType: GitObjectType.Tree, }, { path: '/package.json', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src/index.ts', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src/utils', isFolder: true, gitObjectType: GitObjectType.Tree, }, { path: '/src/utils/helper.ts', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/src/utils/constants.ts', isFolder: false, gitObjectType: GitObjectType.Blob, }, ]; // Sample files/folders for repo2 const mockRepo2RootItems = [ { path: '/', gitObjectType: GitObjectType.Tree, }, { path: '/README.md', isFolder: false, gitObjectType: GitObjectType.Blob, }, { path: '/data.json', isFolder: false, gitObjectType: GitObjectType.Blob, }, ]; let mockConnection: jest.Mocked<WebApi>; let mockGitApi: any; beforeEach(() => { // Clear mocks jest.clearAllMocks(); // Create mock GitApi mockGitApi = { getRepositories: jest.fn().mockResolvedValue(mockRepos), getItems: jest .fn() .mockImplementation((repoId, _projectId, path, recursionLevel) => { if (repoId === 'repo1-id') { if (recursionLevel === VersionControlRecursionType.Full) { return Promise.resolve(mockRepo1FullRecursionItems); } else if (path === '/') { return Promise.resolve(mockRepo1RootItems); } else if (path === '/src') { return Promise.resolve(mockRepo1SrcItems); } } else if (repoId === 'repo2-id') { if (recursionLevel === VersionControlRecursionType.Full) { return Promise.resolve(mockRepo2RootItems); } else if (path === '/') { return Promise.resolve(mockRepo2RootItems); } } return Promise.resolve([]); }), }; // Create mock connection mockConnection = { getGitApi: jest.fn().mockResolvedValue(mockGitApi), } as unknown as jest.Mocked<WebApi>; }); it('should return tree structures for multiple repositories with limited depth', async () => { // Arrange const options = { organizationId: 'testOrg', projectId: 'testProject', depth: 2, // Limited depth }; // Act const result = await getAllRepositoriesTree(mockConnection, options); // Assert expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); expect(result.repositories.length).toBe(3); // Verify repo1 tree const repo1 = result.repositories.find((r) => r.name === 'repo1'); expect(repo1).toBeDefined(); expect(repo1?.tree.length).toBeGreaterThan(0); expect(repo1?.stats.directories).toBeGreaterThan(0); expect(repo1?.stats.files).toBeGreaterThan(0); // Verify repo2 tree const repo2 = result.repositories.find((r) => r.name === 'repo2'); expect(repo2).toBeDefined(); expect(repo2?.tree.length).toBeGreaterThan(0); // Verify repo3 has error (no default branch) const repo3 = result.repositories.find((r) => r.name === 'repo3-api'); expect(repo3).toBeDefined(); expect(repo3?.error).toContain('No default branch found'); // Verify recursion level was set correctly expect(mockGitApi.getItems).toHaveBeenCalledWith( 'repo1-id', 'testProject', '/', VersionControlRecursionType.OneLevel, expect.anything(), expect.anything(), expect.anything(), expect.anything(), expect.anything(), ); }); it('should return tree structures with max depth using Full recursion', async () => { // Arrange const options = { organizationId: 'testOrg', projectId: 'testProject', depth: 0, // Max depth }; // Act const result = await getAllRepositoriesTree(mockConnection, options); // Assert expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); expect(result.repositories.length).toBe(3); // Verify repo1 tree const repo1 = result.repositories.find((r) => r.name === 'repo1'); expect(repo1).toBeDefined(); expect(repo1?.tree.length).toBeGreaterThan(0); // Should include all items, including nested ones expect(repo1?.tree.length).toBe(mockRepo1FullRecursionItems.length - 1); // -1 for root folder // Verify recursion level was set correctly expect(mockGitApi.getItems).toHaveBeenCalledWith( 'repo1-id', 'testProject', '/', VersionControlRecursionType.Full, expect.anything(), expect.anything(), expect.anything(), expect.anything(), expect.anything(), ); // Verify all levels are represented if (repo1) { const level1Items = repo1.tree.filter((item) => item.level === 1); const level2Items = repo1.tree.filter((item) => item.level === 2); const level3Items = repo1.tree.filter((item) => item.level === 3); // Verify we have items at level 1 expect(level1Items.length).toBeGreaterThan(0); // Verify we have items at level 2 (src/something) expect(level2Items.length).toBeGreaterThan(0); // Check for level 3 items if they exist in our mock data if ( mockRepo1FullRecursionItems.some((item) => { const pathSegments = item.path.split('/').filter(Boolean); return pathSegments.length >= 3; }) ) { expect(level3Items.length).toBeGreaterThan(0); } } }); it('should filter repositories by pattern', async () => { // Arrange const options = { organizationId: 'testOrg', projectId: 'testProject', repositoryPattern: '*api*', depth: 1, }; // Act const result = await getAllRepositoriesTree(mockConnection, options); // Assert expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject'); expect(result.repositories.length).toBe(1); expect(result.repositories[0].name).toBe('repo3-api'); }); it('should format repository tree correctly', () => { // Arrange const treeItems: RepositoryTreeItem[] = [ { name: 'src', path: '/src', isFolder: true, level: 1 }, { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 }, { name: 'README.md', path: '/README.md', isFolder: false, level: 1 }, ]; const stats = { directories: 1, files: 2 }; // Act const formatted = formatRepositoryTree('test-repo', treeItems, stats); // Assert expect(formatted).toMatchSnapshot(); }); it('should format complex repository tree structures correctly', () => { // Arrange const treeItems: RepositoryTreeItem[] = [ // Root level files { name: 'README.md', path: '/README.md', isFolder: false, level: 1 }, { name: 'package.json', path: '/package.json', isFolder: false, level: 1, }, { name: '.gitignore', path: '/.gitignore', isFolder: false, level: 1 }, // Multiple folders at root level { name: 'src', path: '/src', isFolder: true, level: 1 }, { name: 'tests', path: '/tests', isFolder: true, level: 1 }, { name: 'docs', path: '/docs', isFolder: true, level: 1 }, // Nested src folder structure { name: 'components', path: '/src/components', isFolder: true, level: 2 }, { name: 'utils', path: '/src/utils', isFolder: true, level: 2 }, { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 }, // Deeply nested components { name: 'Button', path: '/src/components/Button', isFolder: true, level: 3, }, { name: 'Card', path: '/src/components/Card', isFolder: true, level: 3 }, { name: 'Button.tsx', path: '/src/components/Button/Button.tsx', isFolder: false, level: 4, }, { name: 'Button.styles.ts', path: '/src/components/Button/Button.styles.ts', isFolder: false, level: 4, }, { name: 'Button.test.tsx', path: '/src/components/Button/Button.test.tsx', isFolder: false, level: 4, }, { name: 'index.ts', path: '/src/components/Button/index.ts', isFolder: false, level: 4, }, { name: 'Card.tsx', path: '/src/components/Card/Card.tsx', isFolder: false, level: 4, }, // Utils with files { name: 'helpers.ts', path: '/src/utils/helpers.ts', isFolder: false, level: 3, }, { name: 'constants.ts', path: '/src/utils/constants.ts', isFolder: false, level: 3, }, // Empty folder { name: 'assets', path: '/src/assets', isFolder: true, level: 2 }, // Files with special characters { name: 'file-with-dashes.js', path: '/src/file-with-dashes.js', isFolder: false, level: 2, }, { name: 'file_with_underscores.js', path: '/src/file_with_underscores.js', isFolder: false, level: 2, }, // Folders in test directory { name: 'unit', path: '/tests/unit', isFolder: true, level: 2 }, { name: 'integration', path: '/tests/integration', isFolder: true, level: 2, }, // Files in test directories { name: 'setup.js', path: '/tests/setup.js', isFolder: false, level: 2 }, { name: 'example.test.js', path: '/tests/unit/example.test.js', isFolder: false, level: 3, }, // Files in docs { name: 'API.md', path: '/docs/API.md', isFolder: false, level: 2 }, { name: 'CONTRIBUTING.md', path: '/docs/CONTRIBUTING.md', isFolder: false, level: 2, }, ]; const stats = { directories: 10, files: 18 }; // Act const formatted = formatRepositoryTree('complex-repo', treeItems, stats); // Assert expect(formatted).toMatchSnapshot(); }); it('should handle repository errors gracefully', async () => { // Arrange mockGitApi.getItems = jest.fn().mockRejectedValue(new Error('API error')); const options = { organizationId: 'testOrg', projectId: 'testProject', depth: 1, }; // Act const result = await getAllRepositoriesTree(mockConnection, options); // Assert expect(result.repositories.length).toBe(3); const repo1 = result.repositories.find((r) => r.name === 'repo1'); expect(repo1?.error).toBeDefined(); }); }); ``` -------------------------------------------------------------------------------- /src/features/wikis/create-wiki-page/feature.spec.int.ts: -------------------------------------------------------------------------------- ```typescript import { WebApi } from 'azure-devops-node-api'; import { createWikiPage } from './feature'; import { CreateWikiPageSchema } from './schema'; import { getWikiPage } from '../get-wiki-page/feature'; import { getWikis } from '../get-wikis/feature'; import { getTestConnection, shouldSkipIntegrationTest, } from '@/shared/test/test-helpers'; import { getOrgNameFromUrl } from '@/utils/environment'; import { AzureDevOpsError } from '@/shared/errors/azure-devops-errors'; import { z } from 'zod'; // Ensure environment variables are set for testing process.env.AZURE_DEVOPS_DEFAULT_PROJECT = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'default-project'; describe('createWikiPage Integration Tests', () => { let connection: WebApi | null = null; let projectName: string; let orgUrl: string; let organizationId: string; const testPagePath = '/IntegrationTestPage'; const testPagePathSub = '/IntegrationTestPage/SubPage'; const testPagePathDefault = '/DefaultPathPage'; const testPagePathComment = '/CommentTestPage'; beforeAll(async () => { // Mock the required environment variable for testing process.env.AZURE_DEVOPS_ORG_URL = process.env.AZURE_DEVOPS_ORG_URL || 'https://example.visualstudio.com'; // Get and validate required environment variables const envProjectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT; if (!envProjectName) { throw new Error( 'AZURE_DEVOPS_DEFAULT_PROJECT environment variable is required', ); } projectName = envProjectName; const envOrgUrl = process.env.AZURE_DEVOPS_ORG_URL; if (!envOrgUrl) { throw new Error('AZURE_DEVOPS_ORG_URL environment variable is required'); } orgUrl = envOrgUrl; organizationId = getOrgNameFromUrl(orgUrl); // Get a real connection using environment variables connection = await getTestConnection(); }); // Helper function to get a valid wiki ID async function getValidWikiId(): Promise<string | null> { if (!connection) return null; try { // Get available wikis const wikis = await getWikis(connection, { projectId: projectName }); // Skip if no wikis are available if (wikis.length === 0) { console.log('No wikis available in the project'); return null; } // Use the first available wiki const wiki = wikis[0]; if (!wiki.name) { console.log('Wiki name is undefined'); return null; } return wiki.name; } catch (error) { console.error('Error getting wikis:', error); return null; } } test('should create a new wiki page at the root', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get a valid wiki ID const wikiId = await getValidWikiId(); if (!wikiId) { console.log('Skipping test: No valid wiki ID available'); return; } const params: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePath, content: 'This is content for the integration test page (root).', }; try { // Create the wiki page const createdPage = await createWikiPage(params); // Verify the result expect(createdPage).toBeDefined(); expect(createdPage.path).toBe(testPagePath); expect(createdPage.content).toBe(params.content); // Verify by fetching the page const fetchedPage = await getWikiPage({ organizationId, projectId: projectName, wikiId, pagePath: testPagePath, }); expect(fetchedPage).toBeDefined(); expect(typeof fetchedPage).toBe('string'); expect(fetchedPage).toContain(params.content); } catch (error) { console.error('Error in test:', error); throw error; } }); test('should create a new wiki sub-page', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get a valid wiki ID const wikiId = await getValidWikiId(); if (!wikiId) { console.log('Skipping test: No valid wiki ID available'); return; } // First, ensure the parent page exists const parentParams: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePath, content: 'This is the parent page for the sub-page test.', }; try { // Create the parent page await createWikiPage(parentParams); // Now create the sub-page const subPageParams: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePathSub, content: 'This is content for the integration test sub-page.', }; const createdSubPage = await createWikiPage(subPageParams); // Verify the result expect(createdSubPage).toBeDefined(); expect(createdSubPage.path).toBe(testPagePathSub); expect(createdSubPage.content).toBe(subPageParams.content); // Verify by fetching the sub-page const fetchedSubPage = await getWikiPage({ organizationId, projectId: projectName, wikiId, pagePath: testPagePathSub, }); expect(fetchedSubPage).toBeDefined(); expect(typeof fetchedSubPage).toBe('string'); expect(fetchedSubPage).toContain(subPageParams.content); } catch (error) { console.error('Error in test:', error); throw error; } }); test('should update an existing wiki page if path already exists', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get a valid wiki ID const wikiId = await getValidWikiId(); if (!wikiId) { console.log('Skipping test: No valid wiki ID available'); return; } try { // First create a page with initial content const initialParams: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePath, content: 'Initial content.', }; await createWikiPage(initialParams); // Now update the page with new content const updatedParams: z.infer<typeof CreateWikiPageSchema> = { ...initialParams, content: 'Updated content for the page.', }; const updatedPage = await createWikiPage(updatedParams); // Verify the result expect(updatedPage).toBeDefined(); expect(updatedPage.path).toBe(testPagePath); expect(updatedPage.content).toBe(updatedParams.content); // Verify by fetching the page const fetchedPage = await getWikiPage({ organizationId, projectId: projectName, wikiId, pagePath: testPagePath, }); expect(fetchedPage).toBeDefined(); expect(typeof fetchedPage).toBe('string'); expect(fetchedPage).toContain(updatedParams.content); } catch (error) { console.error('Error in test:', error); throw error; } }); test('should create a page with a default path if specified', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get a valid wiki ID const wikiId = await getValidWikiId(); if (!wikiId) { console.log('Skipping test: No valid wiki ID available'); return; } try { const params: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePathDefault, content: 'Content for page created with default path.', }; const createdPage = await createWikiPage(params); // Verify the result expect(createdPage).toBeDefined(); expect(createdPage.path).toBe(testPagePathDefault); expect(createdPage.content).toBe(params.content); // Verify by fetching the page const fetchedPage = await getWikiPage({ organizationId, projectId: projectName, wikiId, pagePath: testPagePathDefault, }); expect(fetchedPage).toBeDefined(); expect(typeof fetchedPage).toBe('string'); expect(fetchedPage).toContain(params.content); } catch (error) { console.error('Error in test:', error); throw error; } }); test('should include comment in the wiki page creation when provided', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } // This connection must be available if we didn't skip if (!connection) { throw new Error( 'Connection should be available when test is not skipped', ); } // Get a valid wiki ID const wikiId = await getValidWikiId(); if (!wikiId) { console.log('Skipping test: No valid wiki ID available'); return; } try { const params: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId, pagePath: testPagePathComment, content: 'Content with comment.', comment: 'This is a test comment for the wiki page creation', }; const createdPage = await createWikiPage(params); // Verify the result expect(createdPage).toBeDefined(); expect(createdPage.path).toBe(testPagePathComment); expect(createdPage.content).toBe(params.content); // Verify by fetching the page const fetchedPage = await getWikiPage({ organizationId, projectId: projectName, wikiId, pagePath: testPagePathComment, }); expect(fetchedPage).toBeDefined(); expect(typeof fetchedPage).toBe('string'); expect(fetchedPage).toContain(params.content); // Note: The API might not return the comment in the response // This test primarily verifies that including a comment doesn't break the API call } catch (error) { console.error('Error in test:', error); throw error; } }); test('should handle error when wiki does not exist', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } const nonExistentWikiId = 'non-existent-wiki-12345'; const params: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: projectName, wikiId: nonExistentWikiId, pagePath: '/test-page', content: 'This should fail.', }; await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); }); test('should handle error when project does not exist', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } const nonExistentProjectId = 'non-existent-project-12345'; const params: z.infer<typeof CreateWikiPageSchema> = { organizationId, projectId: nonExistentProjectId, wikiId: 'any-wiki', pagePath: '/test-page', content: 'This should fail.', }; await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); }); test('should handle error when organization does not exist', async () => { // Skip if no connection is available if (shouldSkipIntegrationTest()) { console.log('Skipping test due to missing connection'); return; } const nonExistentOrgId = 'non-existent-org-12345'; const params: z.infer<typeof CreateWikiPageSchema> = { organizationId: nonExistentOrgId, projectId: projectName, wikiId: 'any-wiki', pagePath: '/test-page', content: 'This should fail.', }; await expect(createWikiPage(params)).rejects.toThrow(AzureDevOpsError); }); }); ``` -------------------------------------------------------------------------------- /src/features/projects/get-project-details/feature.spec.unit.ts: -------------------------------------------------------------------------------- ```typescript import { getProjectDetails } from './feature'; import { AzureDevOpsError, AzureDevOpsResourceNotFoundError, } from '../../../shared/errors'; import { TeamProject, WebApiTeam, } from 'azure-devops-node-api/interfaces/CoreInterfaces'; import { WebApi } from 'azure-devops-node-api'; import { WorkItemType } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; // Create mock interfaces for the APIs we'll use interface MockCoreApi { getProject: jest.Mock<Promise<TeamProject | null>>; getTeams: jest.Mock<Promise<WebApiTeam[]>>; } interface MockWorkItemTrackingApi { getWorkItemTypes: jest.Mock<Promise<WorkItemType[]>>; } interface MockProcessApi { getProcesses: jest.Mock<Promise<any[]>>; getProcessWorkItemTypes: jest.Mock<Promise<any[]>>; } // Create a mock connection that resembles WebApi with minimal implementation interface MockConnection { getCoreApi: jest.Mock<Promise<MockCoreApi>>; getWorkItemTrackingApi: jest.Mock<Promise<MockWorkItemTrackingApi>>; getProcessApi: jest.Mock<Promise<MockProcessApi>>; serverUrl?: string; authHandler?: unknown; rest?: unknown; vsoClient?: unknown; } // Sample data for tests const mockProject = { id: 'project-id', name: 'Test Project', description: 'A test project', url: 'https://dev.azure.com/org/project', state: 1, // wellFormed revision: 123, visibility: 0, // private lastUpdateTime: new Date(), capabilities: { versioncontrol: { sourceControlType: 'Git', }, processTemplate: { templateName: 'Agile', templateTypeId: 'template-guid', }, }, } as unknown as TeamProject; const mockTeams: WebApiTeam[] = [ { id: 'team-guid-1', name: 'Team 1', description: 'First team', url: 'https://dev.azure.com/org/_apis/projects/project-guid/teams/team-guid-1', identityUrl: 'https://vssps.dev.azure.com/org/_apis/Identities/team-guid-1', } as WebApiTeam, { id: 'team-guid-2', name: 'Team 2', description: 'Second team', url: 'https://dev.azure.com/org/_apis/projects/project-guid/teams/team-guid-2', identityUrl: 'https://vssps.dev.azure.com/org/_apis/Identities/team-guid-2', } as WebApiTeam, ]; const mockWorkItemTypes: WorkItemType[] = [ { name: 'User Story', description: 'Tracks user requirements', referenceName: 'Microsoft.VSTS.WorkItemTypes.UserStory', color: 'blue', icon: 'icon-user-story', isDisabled: false, } as WorkItemType, { name: 'Bug', description: 'Tracks defects in the product', referenceName: 'Microsoft.VSTS.WorkItemTypes.Bug', color: 'red', icon: 'icon-bug', isDisabled: false, } as WorkItemType, ]; const mockProcesses = [ { id: 'process-guid', name: 'Agile', description: 'Agile process', isDefault: true, type: 'system', }, ]; const mockProcessWorkItemTypes = [ { name: 'User Story', referenceName: 'Microsoft.VSTS.WorkItemTypes.UserStory', description: 'Tracks user requirements', color: 'blue', icon: 'icon-user-story', isDisabled: false, states: [ { name: 'New', color: 'blue', stateCategory: 'Proposed', }, { name: 'Active', color: 'blue', stateCategory: 'InProgress', }, { name: 'Resolved', color: 'blue', stateCategory: 'InProgress', }, { name: 'Closed', color: 'blue', stateCategory: 'Completed', }, ], fields: [ { name: 'Title', referenceName: 'System.Title', type: 'string', required: true, }, { name: 'Description', referenceName: 'System.Description', type: 'html', }, ], }, { name: 'Bug', referenceName: 'Microsoft.VSTS.WorkItemTypes.Bug', description: 'Tracks defects in the product', color: 'red', icon: 'icon-bug', isDisabled: false, states: [ { name: 'New', color: 'red', stateCategory: 'Proposed', }, { name: 'Active', color: 'red', stateCategory: 'InProgress', }, { name: 'Resolved', color: 'red', stateCategory: 'InProgress', }, { name: 'Closed', color: 'red', stateCategory: 'Completed', }, ], fields: [ { name: 'Title', referenceName: 'System.Title', type: 'string', required: true, }, { name: 'Repro Steps', referenceName: 'Microsoft.VSTS.TCM.ReproSteps', type: 'html', }, ], }, ]; // Unit tests should only focus on isolated logic describe('getProjectDetails unit', () => { test('should throw resource not found error when project is null', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(null), // Simulate project not found getTeams: jest.fn().mockResolvedValue([]), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest.fn().mockResolvedValue({ getWorkItemTypes: jest.fn().mockResolvedValue([]), }), getProcessApi: jest.fn().mockResolvedValue({ getProcesses: jest.fn().mockResolvedValue([]), getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), }), }; // Act & Assert await expect( getProjectDetails(mockConnection as unknown as WebApi, { projectId: 'non-existent-project', }), ).rejects.toThrow(AzureDevOpsResourceNotFoundError); await expect( getProjectDetails(mockConnection as unknown as WebApi, { projectId: 'non-existent-project', }), ).rejects.toThrow("Project 'non-existent-project' not found"); }); test('should return basic project details when no additional options are specified', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(mockProject), getTeams: jest.fn().mockResolvedValue([]), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest.fn().mockResolvedValue({ getWorkItemTypes: jest.fn().mockResolvedValue([]), }), getProcessApi: jest.fn().mockResolvedValue({ getProcesses: jest.fn().mockResolvedValue([]), getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), }), }; // Act const result = await getProjectDetails( mockConnection as unknown as WebApi, { projectId: 'test-project', }, ); // Assert expect(result).toBeDefined(); expect(result.id).toBe(mockProject.id); expect(result.name).toBe(mockProject.name); expect(result.description).toBe(mockProject.description); expect(result.url).toBe(mockProject.url); expect(result.state).toBe(mockProject.state); expect(result.revision).toBe(mockProject.revision); expect(result.visibility).toBe(mockProject.visibility); expect(result.lastUpdateTime).toBe(mockProject.lastUpdateTime); expect(result.capabilities).toEqual(mockProject.capabilities); // Verify that additional details are not included expect(result.process).toBeUndefined(); expect(result.teams).toBeUndefined(); }); test('should include teams when includeTeams is true', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(mockProject), getTeams: jest.fn().mockResolvedValue(mockTeams), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest.fn().mockResolvedValue({ getWorkItemTypes: jest.fn().mockResolvedValue([]), }), getProcessApi: jest.fn().mockResolvedValue({ getProcesses: jest.fn().mockResolvedValue([]), getProcessWorkItemTypes: jest.fn().mockResolvedValue([]), }), }; // Act const result = await getProjectDetails( mockConnection as unknown as WebApi, { projectId: 'test-project', includeTeams: true, }, ); // Assert expect(result).toBeDefined(); expect(result.teams).toBeDefined(); expect(result.teams?.length).toBe(2); expect(result.teams?.[0].id).toBe(mockTeams[0].id); expect(result.teams?.[0].name).toBe(mockTeams[0].name); expect(result.teams?.[1].id).toBe(mockTeams[1].id); expect(result.teams?.[1].name).toBe(mockTeams[1].name); }); test('should include process information when includeProcess is true', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(mockProject), getTeams: jest.fn().mockResolvedValue([]), }; const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { getWorkItemTypes: jest.fn().mockResolvedValue([]), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest .fn() .mockResolvedValue(mockWorkItemTrackingApi), getProcessApi: jest.fn(), }; // Act const result = await getProjectDetails( mockConnection as unknown as WebApi, { projectId: 'test-project', includeProcess: true, }, ); // Assert expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.name).toBe('Agile'); }); test('should include work item types when includeWorkItemTypes is true', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(mockProject), getTeams: jest.fn().mockResolvedValue([]), }; const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { getWorkItemTypes: jest.fn().mockResolvedValue(mockWorkItemTypes), }; const mockProcessApi: MockProcessApi = { getProcesses: jest.fn().mockResolvedValue(mockProcesses), getProcessWorkItemTypes: jest .fn() .mockResolvedValue(mockProcessWorkItemTypes), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest .fn() .mockResolvedValue(mockWorkItemTrackingApi), getProcessApi: jest.fn().mockResolvedValue(mockProcessApi), }; // Act const result = await getProjectDetails( mockConnection as unknown as WebApi, { projectId: 'test-project', includeWorkItemTypes: true, includeProcess: true, }, ); // Assert expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.workItemTypes).toBeDefined(); expect(result.process?.workItemTypes?.length).toBe(2); expect(result.process?.workItemTypes?.[0].name).toBe('User Story'); expect(result.process?.workItemTypes?.[1].name).toBe('Bug'); }); test('should include fields when includeFields is true', async () => { // Arrange const mockCoreApi: MockCoreApi = { getProject: jest.fn().mockResolvedValue(mockProject), getTeams: jest.fn().mockResolvedValue([]), }; const mockWorkItemTrackingApi: MockWorkItemTrackingApi = { getWorkItemTypes: jest.fn().mockResolvedValue(mockWorkItemTypes), }; const mockProcessApi: MockProcessApi = { getProcesses: jest.fn().mockResolvedValue(mockProcesses), getProcessWorkItemTypes: jest .fn() .mockResolvedValue(mockProcessWorkItemTypes), }; const mockConnection: MockConnection = { getCoreApi: jest.fn().mockResolvedValue(mockCoreApi), getWorkItemTrackingApi: jest .fn() .mockResolvedValue(mockWorkItemTrackingApi), getProcessApi: jest.fn().mockResolvedValue(mockProcessApi), }; // Act const result = await getProjectDetails( mockConnection as unknown as WebApi, { projectId: 'test-project', includeWorkItemTypes: true, includeFields: true, includeProcess: true, }, ); // Assert expect(result).toBeDefined(); expect(result.process).toBeDefined(); expect(result.process?.workItemTypes).toBeDefined(); expect(result.process?.workItemTypes?.[0].fields).toBeDefined(); expect(result.process?.workItemTypes?.[0].fields?.length).toBe(2); expect(result.process?.workItemTypes?.[0].fields?.[0].name).toBe('Title'); expect(result.process?.workItemTypes?.[0].fields?.[1].name).toBe( 'Description', ); }); test('should propagate custom errors when thrown internally', async () => { // Arrange const mockConnection: MockConnection = { getCoreApi: jest.fn().mockImplementation(() => { throw new AzureDevOpsError('Custom error'); }), getWorkItemTrackingApi: jest.fn(), getProcessApi: jest.fn(), }; // Act & Assert await expect( getProjectDetails(mockConnection as unknown as WebApi, { projectId: 'test-project', }), ).rejects.toThrow(AzureDevOpsError); await expect( getProjectDetails(mockConnection as unknown as WebApi, { projectId: 'test-project', }), ).rejects.toThrow('Custom error'); }); test('should wrap unexpected errors in a friendly error message', async () => { // Arrange const mockConnection: MockConnection = { getCoreApi: jest.fn().mockImplementation(() => { throw new Error('Unexpected error'); }), getWorkItemTrackingApi: jest.fn(), getProcessApi: jest.fn(), }; // Act & Assert await expect( getProjectDetails(mockConnection as unknown as WebApi, { projectId: 'test-project', }), ).rejects.toThrow('Failed to get project details: Unexpected error'); }); }); ```