This is page 2 of 10. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .clinerules
├── .env.example
├── .eslintrc.json
├── .github
│ ├── copilot-instructions.md
│ ├── FUNDING.yml
│ ├── release-please-config.json
│ ├── release-please-manifest.json
│ ├── skills
│ │ ├── azure-devops-rest-api
│ │ │ ├── references
│ │ │ │ └── api_areas.md
│ │ │ ├── scripts
│ │ │ │ ├── clone_specs.sh
│ │ │ │ └── find_endpoint.py
│ │ │ └── SKILL.md
│ │ └── skill-creator
│ │ ├── LICENSE.txt
│ │ ├── references
│ │ │ ├── output-patterns.md
│ │ │ └── workflows.md
│ │ ├── scripts
│ │ │ ├── init_skill.py
│ │ │ └── quick_validate.py
│ │ └── SKILL.md
│ └── workflows
│ ├── main.yml
│ ├── release-please.yml
│ └── update-skills.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
│ │ │ ├── artifacts.spec.unit.ts
│ │ │ ├── artifacts.ts
│ │ │ ├── download-pipeline-artifact
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── get-pipeline
│ │ │ │ ├── feature.spec.int.ts
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── get-pipeline-log
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── get-pipeline-run
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── helpers.ts
│ │ │ ├── index.spec.unit.ts
│ │ │ ├── index.ts
│ │ │ ├── list-pipeline-runs
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── list-pipelines
│ │ │ │ ├── feature.spec.int.ts
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── pipeline-timeline
│ │ │ │ ├── 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-changes
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ └── index.ts
│ │ │ ├── get-pull-request-checks
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ └── index.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
│ │ │ ├── create-branch
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ └── index.ts
│ │ │ ├── create-commit
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ └── index.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
│ │ │ ├── get-repository-tree
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.ts
│ │ │ │ └── index.ts
│ │ │ ├── index.spec.unit.ts
│ │ │ ├── index.ts
│ │ │ ├── list-commits
│ │ │ │ ├── feature.spec.unit.ts
│ │ │ │ ├── feature.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
│ ├── types
│ │ └── diff.d.ts
│ └── utils
│ ├── environment.spec.unit.ts
│ └── environment.ts
├── tasks.json
├── tests
│ └── setup.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/src/features/pipelines/get-pipeline/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | AzureDevOpsError,
4 | AzureDevOpsAuthenticationError,
5 | AzureDevOpsResourceNotFoundError,
6 | } from '../../../shared/errors';
7 | import { GetPipelineOptions, Pipeline } from '../types';
8 |
9 | /**
10 | * Get a specific pipeline by ID
11 | *
12 | * @param connection The Azure DevOps WebApi connection
13 | * @param options Options for getting a pipeline
14 | * @returns Pipeline details
15 | */
16 | export async function getPipeline(
17 | connection: WebApi,
18 | options: GetPipelineOptions,
19 | ): Promise<Pipeline> {
20 | try {
21 | const pipelinesApi = await connection.getPipelinesApi();
22 | const { projectId, pipelineId, pipelineVersion } = options;
23 |
24 | // Call the pipelines API to get the pipeline
25 | const pipeline = await pipelinesApi.getPipeline(
26 | projectId,
27 | pipelineId,
28 | pipelineVersion,
29 | );
30 |
31 | // If pipeline not found, API returns null instead of throwing error
32 | if (pipeline === null) {
33 | throw new AzureDevOpsResourceNotFoundError(
34 | `Pipeline not found with ID: ${pipelineId}`,
35 | );
36 | }
37 |
38 | return pipeline;
39 | } catch (error) {
40 | // Handle specific error types
41 | if (error instanceof AzureDevOpsError) {
42 | throw error;
43 | }
44 |
45 | // Check for specific error types and convert to appropriate Azure DevOps errors
46 | if (error instanceof Error) {
47 | if (
48 | error.message.includes('Authentication') ||
49 | error.message.includes('Unauthorized') ||
50 | error.message.includes('401')
51 | ) {
52 | throw new AzureDevOpsAuthenticationError(
53 | `Failed to authenticate: ${error.message}`,
54 | );
55 | }
56 |
57 | if (
58 | error.message.includes('not found') ||
59 | error.message.includes('does not exist') ||
60 | error.message.includes('404')
61 | ) {
62 | throw new AzureDevOpsResourceNotFoundError(
63 | `Pipeline or project not found: ${error.message}`,
64 | );
65 | }
66 | }
67 |
68 | // Otherwise, wrap it in a generic error
69 | throw new AzureDevOpsError(
70 | `Failed to get pipeline: ${error instanceof Error ? error.message : String(error)}`,
71 | );
72 | }
73 | }
74 |
```
--------------------------------------------------------------------------------
/docs/tools/organizations.md:
--------------------------------------------------------------------------------
```markdown
1 | # Azure DevOps Organizations Tools
2 |
3 | This document describes the tools available for working with Azure DevOps organizations.
4 |
5 | ## list_organizations
6 |
7 | Lists all Azure DevOps organizations accessible to the authenticated user.
8 |
9 | ### Description
10 |
11 | The `list_organizations` tool retrieves all Azure DevOps organizations that the authenticated user has access to. This is useful for discovering which organizations are available before performing operations on specific projects or repositories.
12 |
13 | Unlike most other tools in this server, this tool uses Axios for direct API calls rather than the Azure DevOps Node API client, as the WebApi client doesn't support the organizations endpoint.
14 |
15 | ### Parameters
16 |
17 | This tool doesn't require any parameters.
18 |
19 | ```json
20 | {
21 | // No parameters required
22 | }
23 | ```
24 |
25 | ### Response
26 |
27 | The tool returns an array of organization objects, each containing:
28 |
29 | - `id`: The unique identifier of the organization
30 | - `name`: The name of the organization
31 | - `url`: The URL of the organization
32 |
33 | Example response:
34 |
35 | ```json
36 | [
37 | {
38 | "id": "org1-id",
39 | "name": "org1-name",
40 | "url": "https://dev.azure.com/org1-name"
41 | },
42 | {
43 | "id": "org2-id",
44 | "name": "org2-name",
45 | "url": "https://dev.azure.com/org2-name"
46 | }
47 | ]
48 | ```
49 |
50 | ### Error Handling
51 |
52 | The tool may throw the following errors:
53 |
54 | - `AzureDevOpsAuthenticationError`: If authentication fails or the user profile cannot be retrieved
55 | - General errors: If the accounts API call fails or other unexpected errors occur
56 |
57 | ### Example Usage
58 |
59 | ```typescript
60 | // Example MCP client call
61 | const result = await mcpClient.callTool('list_organizations', {});
62 | console.log(result);
63 | ```
64 |
65 | ### Implementation Details
66 |
67 | This tool uses a two-step process to retrieve organizations:
68 |
69 | 1. First, it gets the user profile from `https://app.vssps.visualstudio.com/_apis/profile/profiles/me`
70 | 2. Then it extracts the `publicAlias` from the profile response
71 | 3. Finally, it uses the `publicAlias` to get organizations from `https://app.vssps.visualstudio.com/_apis/accounts?memberId={publicAlias}`
72 |
73 | Authentication is handled using Basic Auth with the Personal Access Token.
74 |
```
--------------------------------------------------------------------------------
/src/features/projects/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import { defaultProject, defaultOrg } from '../../utils/environment';
3 |
4 | /**
5 | * Schema for getting a project
6 | */
7 | export const GetProjectSchema = z.object({
8 | projectId: z
9 | .string()
10 | .optional()
11 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
12 | organizationId: z
13 | .string()
14 | .optional()
15 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
16 | });
17 |
18 | /**
19 | * Schema for getting detailed project information
20 | */
21 | export const GetProjectDetailsSchema = z.object({
22 | projectId: z
23 | .string()
24 | .optional()
25 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
26 | organizationId: z
27 | .string()
28 | .optional()
29 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
30 | includeProcess: z
31 | .boolean()
32 | .optional()
33 | .default(false)
34 | .describe('Include process information in the project result'),
35 | includeWorkItemTypes: z
36 | .boolean()
37 | .optional()
38 | .default(false)
39 | .describe('Include work item types and their structure'),
40 | includeFields: z
41 | .boolean()
42 | .optional()
43 | .default(false)
44 | .describe('Include field information for work item types'),
45 | includeTeams: z
46 | .boolean()
47 | .optional()
48 | .default(false)
49 | .describe('Include associated teams in the project result'),
50 | expandTeamIdentity: z
51 | .boolean()
52 | .optional()
53 | .default(false)
54 | .describe('Expand identity information in the team objects'),
55 | });
56 |
57 | /**
58 | * Schema for listing projects
59 | */
60 | export const ListProjectsSchema = z.object({
61 | organizationId: z
62 | .string()
63 | .optional()
64 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
65 | stateFilter: z
66 | .number()
67 | .optional()
68 | .describe(
69 | 'Filter on team project state (0: all, 1: well-formed, 2: creating, 3: deleting, 4: new)',
70 | ),
71 | top: z.number().optional().describe('Maximum number of projects to return'),
72 | skip: z.number().optional().describe('Number of projects to skip'),
73 | continuationToken: z
74 | .number()
75 | .optional()
76 | .describe('Gets the projects after the continuation token provided'),
77 | });
78 |
```
--------------------------------------------------------------------------------
/src/features/repositories/list-repositories/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { listRepositories } from './feature';
2 | import { AzureDevOpsError } from '../../../shared/errors';
3 |
4 | // Unit tests should only focus on isolated logic
5 | describe('listRepositories unit', () => {
6 | test('should return empty array when no repositories are found', async () => {
7 | // Arrange
8 | const mockConnection: any = {
9 | getGitApi: jest.fn().mockImplementation(() => ({
10 | getRepositories: jest.fn().mockResolvedValue([]), // No repositories found
11 | })),
12 | };
13 |
14 | // Act
15 | const result = await listRepositories(mockConnection, {
16 | projectId: 'test-project',
17 | });
18 |
19 | // Assert
20 | expect(result).toEqual([]);
21 | });
22 |
23 | test('should propagate custom errors when thrown internally', async () => {
24 | // Arrange
25 | const mockConnection: any = {
26 | getGitApi: jest.fn().mockImplementation(() => {
27 | throw new AzureDevOpsError('Custom error');
28 | }),
29 | };
30 |
31 | // Act & Assert
32 | await expect(
33 | listRepositories(mockConnection, { projectId: 'test-project' }),
34 | ).rejects.toThrow(AzureDevOpsError);
35 |
36 | await expect(
37 | listRepositories(mockConnection, { projectId: 'test-project' }),
38 | ).rejects.toThrow('Custom error');
39 | });
40 |
41 | test('should wrap unexpected errors in a friendly error message', async () => {
42 | // Arrange
43 | const mockConnection: any = {
44 | getGitApi: jest.fn().mockImplementation(() => {
45 | throw new Error('Unexpected error');
46 | }),
47 | };
48 |
49 | // Act & Assert
50 | await expect(
51 | listRepositories(mockConnection, { projectId: 'test-project' }),
52 | ).rejects.toThrow('Failed to list repositories: Unexpected error');
53 | });
54 |
55 | test('should respect the includeLinks option', async () => {
56 | // Arrange
57 | const mockGetRepositories = jest.fn().mockResolvedValue([]);
58 | const mockConnection: any = {
59 | getGitApi: jest.fn().mockImplementation(() => ({
60 | getRepositories: mockGetRepositories,
61 | })),
62 | };
63 |
64 | // Act
65 | await listRepositories(mockConnection, {
66 | projectId: 'test-project',
67 | includeLinks: true,
68 | });
69 |
70 | // Assert
71 | expect(mockGetRepositories).toHaveBeenCalledWith('test-project', true);
72 | });
73 | });
74 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/tool-definitions.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { zodToJsonSchema } from 'zod-to-json-schema';
2 | import { ToolDefinition } from '../../shared/types/tool-definition';
3 | import {
4 | CreatePullRequestSchema,
5 | ListPullRequestsSchema,
6 | GetPullRequestCommentsSchema,
7 | AddPullRequestCommentSchema,
8 | UpdatePullRequestSchema,
9 | GetPullRequestChangesSchema,
10 | GetPullRequestChecksSchema,
11 | } from './schemas';
12 |
13 | /**
14 | * List of pull requests tools
15 | */
16 | export const pullRequestsTools: ToolDefinition[] = [
17 | {
18 | name: 'create_pull_request',
19 | description:
20 | 'Create a new pull request, including reviewers, linked work items, and optional tags',
21 | inputSchema: zodToJsonSchema(CreatePullRequestSchema),
22 | },
23 | {
24 | name: 'list_pull_requests',
25 | description: 'List pull requests in a repository',
26 | inputSchema: zodToJsonSchema(ListPullRequestsSchema),
27 | },
28 | {
29 | name: 'get_pull_request_comments',
30 | description: 'Get comments from a specific pull request',
31 | inputSchema: zodToJsonSchema(GetPullRequestCommentsSchema),
32 | },
33 | {
34 | name: 'add_pull_request_comment',
35 | description:
36 | 'Add a comment to a pull request (reply to existing comments or create new threads)',
37 | inputSchema: zodToJsonSchema(AddPullRequestCommentSchema),
38 | },
39 | {
40 | name: 'update_pull_request',
41 | description:
42 | 'Update an existing pull request with new properties, manage reviewers and work items, and add or remove tags',
43 | inputSchema: zodToJsonSchema(UpdatePullRequestSchema),
44 | },
45 | {
46 | name: 'get_pull_request_changes',
47 | description:
48 | 'Get the files changed in a pull request, their unified diffs, source/target branch names, and the status of policy evaluations',
49 | inputSchema: zodToJsonSchema(GetPullRequestChangesSchema),
50 | },
51 | {
52 | name: 'get_pull_request_checks',
53 | description: [
54 | 'Summarize the latest status checks and policy evaluations for a pull request.',
55 | '- Surfaces pipeline and run identifiers so you can jump straight to the blocking validation.',
56 | '- Pair with pipeline tools (e.g., get_pipeline_run, pipeline_timeline) to inspect failures in depth.',
57 | ].join('\n'),
58 | inputSchema: zodToJsonSchema(GetPullRequestChecksSchema),
59 | },
60 | ];
61 |
```
--------------------------------------------------------------------------------
/src/utils/environment.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Mock the environment module before importing
2 | jest.mock('./environment', () => {
3 | const original = jest.requireActual('./environment');
4 | return {
5 | ...original,
6 | // We'll keep getOrgNameFromUrl as is for its own tests
7 | getOrgNameFromUrl: original.getOrgNameFromUrl,
8 | };
9 | });
10 |
11 | import { getOrgNameFromUrl } from './environment';
12 |
13 | describe('environment utilities', () => {
14 | // Store original environment variables
15 | const originalEnv = { ...process.env };
16 |
17 | // Reset environment variables after each test
18 | afterEach(() => {
19 | process.env = { ...originalEnv };
20 | jest.resetModules();
21 | });
22 |
23 | describe('getOrgNameFromUrl', () => {
24 | it('should extract organization name from Azure DevOps URL', () => {
25 | const url = 'https://dev.azure.com/test-organization';
26 | expect(getOrgNameFromUrl(url)).toBe('test-organization');
27 | });
28 |
29 | it('should handle URLs with paths after the organization name', () => {
30 | const url = 'https://dev.azure.com/test-organization/project';
31 | expect(getOrgNameFromUrl(url)).toBe('test-organization');
32 | });
33 |
34 | it('should return "unknown-organization" when URL is undefined', () => {
35 | expect(getOrgNameFromUrl(undefined)).toBe('unknown-organization');
36 | });
37 |
38 | it('should return "unknown-organization" when URL is empty', () => {
39 | expect(getOrgNameFromUrl('')).toBe('unknown-organization');
40 | });
41 |
42 | it('should return "unknown-organization" when URL does not match pattern', () => {
43 | const url = 'https://example.com/test-organization';
44 | expect(getOrgNameFromUrl(url)).toBe('unknown-organization');
45 | });
46 | });
47 |
48 | describe('defaultProject and defaultOrg', () => {
49 | // Since we can't easily test the environment variable initialization directly,
50 | // we'll test the getOrgNameFromUrl function which is used to derive defaultOrg
51 |
52 | it('should handle the real default case', () => {
53 | // This test is more of a documentation than a real test
54 | const orgNameFromUrl = getOrgNameFromUrl(
55 | process.env.AZURE_DEVOPS_ORG_URL,
56 | );
57 | // We can't assert an exact value since it depends on the environment
58 | expect(typeof orgNameFromUrl).toBe('string');
59 | });
60 | });
61 | });
62 |
```
--------------------------------------------------------------------------------
/src/features/repositories/get-repository-tree/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getRepositoryTree } from './feature';
2 | import { AzureDevOpsError } from '../../../shared/errors';
3 |
4 | describe('getRepositoryTree unit', () => {
5 | test('should build tree structure from root', async () => {
6 | const mockConnection: any = {
7 | getGitApi: jest.fn().mockResolvedValue({
8 | getRepository: jest.fn().mockResolvedValue({
9 | id: '1',
10 | name: 'test-repo',
11 | defaultBranch: 'refs/heads/main',
12 | }),
13 | getItems: jest.fn().mockResolvedValue([
14 | { path: '/', isFolder: true },
15 | { path: '/README.md', isFolder: false },
16 | { path: '/src', isFolder: true },
17 | { path: '/src/index.ts', isFolder: false },
18 | ]),
19 | }),
20 | };
21 |
22 | const result = await getRepositoryTree(mockConnection, {
23 | projectId: 'proj',
24 | repositoryId: '1',
25 | });
26 |
27 | expect(result.name).toBe('test-repo');
28 | expect(result.stats.files).toBe(2);
29 | expect(result.stats.directories).toBe(1);
30 | expect(result.tree.length).toBe(3);
31 | });
32 |
33 | test('should respect depth when provided', async () => {
34 | const mockConnection: any = {
35 | getGitApi: jest.fn().mockResolvedValue({
36 | getRepository: jest.fn().mockResolvedValue({
37 | id: '1',
38 | name: 'test-repo',
39 | defaultBranch: 'refs/heads/main',
40 | }),
41 | getItems: jest.fn().mockResolvedValue([
42 | { path: '/src', isFolder: true },
43 | { path: '/src/index.ts', isFolder: false },
44 | { path: '/src/utils', isFolder: true },
45 | { path: '/src/utils/helper.ts', isFolder: false },
46 | ]),
47 | }),
48 | };
49 |
50 | const result = await getRepositoryTree(mockConnection, {
51 | projectId: 'proj',
52 | repositoryId: '1',
53 | path: '/src',
54 | depth: 1,
55 | });
56 |
57 | expect(result.tree).toHaveLength(2);
58 | expect(result.stats.files).toBe(1);
59 | expect(result.stats.directories).toBe(1);
60 | });
61 |
62 | test('should throw AzureDevOpsError when repository not found', async () => {
63 | const mockConnection: any = {
64 | getGitApi: jest.fn().mockResolvedValue({
65 | getRepository: jest.fn().mockResolvedValue(null),
66 | }),
67 | };
68 |
69 | await expect(
70 | getRepositoryTree(mockConnection, {
71 | projectId: 'p',
72 | repositoryId: 'missing',
73 | }),
74 | ).rejects.toThrow(AzureDevOpsError);
75 | });
76 | });
77 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/tool-definitions.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { zodToJsonSchema } from 'zod-to-json-schema';
2 | import { ToolDefinition } from '../../shared/types/tool-definition';
3 | import { ListPipelinesSchema } from './list-pipelines/schema';
4 | import { GetPipelineSchema } from './get-pipeline/schema';
5 | import { ListPipelineRunsSchema } from './list-pipeline-runs/schema';
6 | import { GetPipelineRunSchema } from './get-pipeline-run/schema';
7 | import { DownloadPipelineArtifactSchema } from './download-pipeline-artifact/schema';
8 | import { GetPipelineTimelineSchema } from './pipeline-timeline/schema';
9 | import { GetPipelineLogSchema } from './get-pipeline-log/schema';
10 | import { TriggerPipelineSchema } from './trigger-pipeline/schema';
11 |
12 | /**
13 | * List of pipelines tools
14 | */
15 | export const pipelinesTools: ToolDefinition[] = [
16 | {
17 | name: 'list_pipelines',
18 | description: 'List pipelines in a project',
19 | inputSchema: zodToJsonSchema(ListPipelinesSchema),
20 | mcp_enabled: true,
21 | },
22 | {
23 | name: 'get_pipeline',
24 | description: 'Get details of a specific pipeline',
25 | inputSchema: zodToJsonSchema(GetPipelineSchema),
26 | mcp_enabled: true,
27 | },
28 | {
29 | name: 'list_pipeline_runs',
30 | description: 'List recent runs for a pipeline',
31 | inputSchema: zodToJsonSchema(ListPipelineRunsSchema),
32 | mcp_enabled: true,
33 | },
34 | {
35 | name: 'get_pipeline_run',
36 | description: 'Get details for a specific pipeline run',
37 | inputSchema: zodToJsonSchema(GetPipelineRunSchema),
38 | mcp_enabled: true,
39 | },
40 | {
41 | name: 'download_pipeline_artifact',
42 | description:
43 | 'Download a file from a pipeline run artifact and return its textual content',
44 | inputSchema: zodToJsonSchema(DownloadPipelineArtifactSchema),
45 | mcp_enabled: true,
46 | },
47 | {
48 | name: 'pipeline_timeline',
49 | description:
50 | 'Retrieve the timeline of stages and jobs for a pipeline run, to reduce the amount of data returned, you can filter by state and result',
51 | inputSchema: zodToJsonSchema(GetPipelineTimelineSchema),
52 | mcp_enabled: true,
53 | },
54 | {
55 | name: 'get_pipeline_log',
56 | description:
57 | 'Retrieve a specific pipeline log using the timeline log identifier',
58 | inputSchema: zodToJsonSchema(GetPipelineLogSchema),
59 | mcp_enabled: true,
60 | },
61 | {
62 | name: 'trigger_pipeline',
63 | description: 'Trigger a pipeline run',
64 | inputSchema: zodToJsonSchema(TriggerPipelineSchema),
65 | mcp_enabled: true,
66 | },
67 | ];
68 |
```
--------------------------------------------------------------------------------
/src/features/wikis/get-wiki-page/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { getWikiPage } from './feature';
3 | import { getWikis } from '../get-wikis/feature';
4 | import {
5 | getTestConnection,
6 | shouldSkipIntegrationTest,
7 | } from '@/shared/test/test-helpers';
8 | import { getOrgNameFromUrl } from '@/utils/environment';
9 |
10 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT =
11 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'default-project';
12 |
13 | describe('getWikiPage integration', () => {
14 | let connection: WebApi | null = null;
15 | let projectName: string;
16 | let orgUrl: string;
17 |
18 | beforeAll(async () => {
19 | // Mock the required environment variable for testing
20 | process.env.AZURE_DEVOPS_ORG_URL =
21 | process.env.AZURE_DEVOPS_ORG_URL || 'https://example.visualstudio.com';
22 | // Get a real connection using environment variables
23 | connection = await getTestConnection();
24 |
25 | // Get and validate required environment variables
26 | const envProjectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT;
27 | if (!envProjectName) {
28 | throw new Error(
29 | 'AZURE_DEVOPS_DEFAULT_PROJECT environment variable is required',
30 | );
31 | }
32 | projectName = envProjectName;
33 |
34 | const envOrgUrl = process.env.AZURE_DEVOPS_ORG_URL;
35 | if (!envOrgUrl) {
36 | throw new Error('AZURE_DEVOPS_ORG_URL environment variable is required');
37 | }
38 | orgUrl = envOrgUrl;
39 | });
40 |
41 | test('should retrieve a wiki page', async () => {
42 | // Skip if no connection is available
43 | if (shouldSkipIntegrationTest()) {
44 | return;
45 | }
46 |
47 | // This connection must be available if we didn't skip
48 | if (!connection) {
49 | throw new Error(
50 | 'Connection should be available when test is not skipped',
51 | );
52 | }
53 |
54 | // First get available wikis
55 | const wikis = await getWikis(connection, { projectId: projectName });
56 |
57 | // Skip if no wikis are available
58 | if (wikis.length === 0) {
59 | console.log('Skipping test: No wikis available in the project');
60 | return;
61 | }
62 |
63 | // Use the first available wiki
64 | const wiki = wikis[0];
65 | if (!wiki.name) {
66 | throw new Error('Wiki name is undefined');
67 | }
68 |
69 | // Get the wiki page
70 | const result = await getWikiPage({
71 | organizationId: getOrgNameFromUrl(orgUrl),
72 | projectId: projectName,
73 | wikiId: wiki.name,
74 | pagePath: '/test',
75 | });
76 |
77 | // Verify the result
78 | expect(result).toBeDefined();
79 | expect(typeof result).toBe('string');
80 | });
81 | });
82 |
```
--------------------------------------------------------------------------------
/src/features/projects/get-project/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getProject } from './feature';
2 | import {
3 | AzureDevOpsError,
4 | AzureDevOpsResourceNotFoundError,
5 | } from '../../../shared/errors';
6 | import { TeamProject } from 'azure-devops-node-api/interfaces/CoreInterfaces';
7 | import { WebApi } from 'azure-devops-node-api';
8 |
9 | // Create a partial mock interface for ICoreApi
10 | interface MockCoreApi {
11 | getProject: jest.Mock<Promise<TeamProject | null>>;
12 | }
13 |
14 | // Create a mock connection that resembles WebApi with minimal implementation
15 | interface MockConnection {
16 | getCoreApi: jest.Mock<Promise<MockCoreApi>>;
17 | serverUrl?: string;
18 | authHandler?: unknown;
19 | rest?: unknown;
20 | vsoClient?: unknown;
21 | }
22 |
23 | // Unit tests should only focus on isolated logic
24 | describe('getProject unit', () => {
25 | test('should throw resource not found error when project is null', async () => {
26 | // Arrange
27 | const mockCoreApi: MockCoreApi = {
28 | getProject: jest.fn().mockResolvedValue(null), // Simulate project not found
29 | };
30 |
31 | const mockConnection: MockConnection = {
32 | getCoreApi: jest.fn().mockResolvedValue(mockCoreApi),
33 | };
34 |
35 | // Act & Assert
36 | await expect(
37 | getProject(mockConnection as unknown as WebApi, 'non-existent-project'),
38 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError);
39 |
40 | await expect(
41 | getProject(mockConnection as unknown as WebApi, 'non-existent-project'),
42 | ).rejects.toThrow("Project 'non-existent-project' not found");
43 | });
44 |
45 | test('should propagate custom errors when thrown internally', async () => {
46 | // Arrange
47 | const mockConnection: MockConnection = {
48 | getCoreApi: jest.fn().mockImplementation(() => {
49 | throw new AzureDevOpsError('Custom error');
50 | }),
51 | };
52 |
53 | // Act & Assert
54 | await expect(
55 | getProject(mockConnection as unknown as WebApi, 'test-project'),
56 | ).rejects.toThrow(AzureDevOpsError);
57 |
58 | await expect(
59 | getProject(mockConnection as unknown as WebApi, 'test-project'),
60 | ).rejects.toThrow('Custom error');
61 | });
62 |
63 | test('should wrap unexpected errors in a friendly error message', async () => {
64 | // Arrange
65 | const mockConnection: MockConnection = {
66 | getCoreApi: jest.fn().mockImplementation(() => {
67 | throw new Error('Unexpected error');
68 | }),
69 | };
70 |
71 | // Act & Assert
72 | await expect(
73 | getProject(mockConnection as unknown as WebApi, 'test-project'),
74 | ).rejects.toThrow('Failed to get project: Unexpected error');
75 | });
76 | });
77 |
```
--------------------------------------------------------------------------------
/finish_task.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Check if a PR title is provided
4 | if [ -z "$1" ]; then
5 | echo "Usage: $0 <pr_title> [pr_description]"
6 | echo "Example: $0 \"Add user authentication\" \"This PR implements user login and registration\""
7 | exit 1
8 | fi
9 |
10 | PR_TITLE="$1"
11 | PR_DESCRIPTION="${2:-"No description provided."}"
12 |
13 | # Get current branch name
14 | CURRENT_BRANCH=$(git symbolic-ref --short HEAD)
15 | if [ "$CURRENT_BRANCH" = "main" ]; then
16 | echo "Error: You are on the main branch. Please switch to a feature branch."
17 | exit 1
18 | fi
19 |
20 | # Check if there are any uncommitted changes
21 | if ! git diff --quiet || ! git diff --staged --quiet; then
22 | # Stage all changes
23 | echo "Staging all changes..."
24 | git add .
25 |
26 | # Commit changes
27 | echo "Committing changes with title: $PR_TITLE"
28 | git commit -m "$PR_TITLE" -m "$PR_DESCRIPTION"
29 |
30 | if [ $? -ne 0 ]; then
31 | echo "Failed to commit changes."
32 | exit 1
33 | fi
34 |
35 | # Push changes to remote
36 | echo "Pushing changes to origin/$CURRENT_BRANCH..."
37 | git push -u origin "$CURRENT_BRANCH"
38 |
39 | if [ $? -ne 0 ]; then
40 | echo "Failed to push changes to remote."
41 | exit 1
42 | fi
43 | else
44 | echo "No uncommitted changes found. Proceeding with PR creation for already committed changes."
45 | fi
46 |
47 | # Create PR using GitHub CLI
48 | echo "Creating pull request..."
49 | if command -v gh &> /dev/null; then
50 | PR_URL=$(gh pr create --title "$PR_TITLE" --body "$PR_DESCRIPTION" --base main --head "$CURRENT_BRANCH")
51 |
52 | if [ $? -eq 0 ]; then
53 | echo "Pull request created successfully!"
54 | echo "PR URL: $PR_URL"
55 |
56 | # Try to open the PR URL in the default browser
57 | if command -v xdg-open &> /dev/null; then
58 | xdg-open "$PR_URL" &> /dev/null & # Linux
59 | elif command -v open &> /dev/null; then
60 | open "$PR_URL" &> /dev/null & # macOS
61 | elif command -v start &> /dev/null; then
62 | start "$PR_URL" &> /dev/null & # Windows
63 | else
64 | echo "Could not automatically open the PR in your browser."
65 | fi
66 | else
67 | echo "Failed to create pull request using GitHub CLI."
68 | echo "Please create a PR manually at: https://github.com/$(git config --get remote.origin.url | sed 's/.*github.com[:\/]\(.*\)\.git/\1/')/pull/new/$CURRENT_BRANCH"
69 | fi
70 | else
71 | echo "GitHub CLI (gh) not found. Please install it to create PRs from the command line."
72 | echo "You can create a PR manually at: https://github.com/$(git config --get remote.origin.url | sed 's/.*github.com[:\/]\(.*\)\.git/\1/')/pull/new/$CURRENT_BRANCH"
73 | fi
74 |
75 | echo "Task completion workflow finished!"
```
--------------------------------------------------------------------------------
/src/features/repositories/get-repository/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { getRepository } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 |
8 | describe('getRepository integration', () => {
9 | let connection: WebApi | null = null;
10 | let projectName: string;
11 |
12 | beforeAll(async () => {
13 | // Get a real connection using environment variables
14 | connection = await getTestConnection();
15 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
16 | });
17 |
18 | test('should retrieve a real repository from Azure DevOps', async () => {
19 | // Skip if no connection is available
20 | if (shouldSkipIntegrationTest()) {
21 | return;
22 | }
23 |
24 | // This connection must be available if we didn't skip
25 | if (!connection) {
26 | throw new Error(
27 | 'Connection should be available when test is not skipped',
28 | );
29 | }
30 |
31 | // First, get a list of repos to find one to test with
32 | const gitApi = await connection.getGitApi();
33 | const repos = await gitApi.getRepositories(projectName);
34 |
35 | // Skip if no repos are available
36 | if (!repos || repos.length === 0) {
37 | console.log('Skipping test: No repositories available in the project');
38 | return;
39 | }
40 |
41 | // Use the first repo as a test subject
42 | const testRepo = repos[0];
43 |
44 | // Act - make an actual API call to Azure DevOps
45 | const result = await getRepository(
46 | connection,
47 | projectName,
48 | testRepo.name || testRepo.id || '',
49 | );
50 |
51 | // Assert on the actual response
52 | expect(result).toBeDefined();
53 | expect(result.id).toBe(testRepo.id);
54 | expect(result.name).toBe(testRepo.name);
55 | expect(result.project).toBeDefined();
56 | if (result.project) {
57 | expect(result.project.name).toBe(projectName);
58 | }
59 | });
60 |
61 | test('should throw error when repository is not found', async () => {
62 | // Skip if no connection is available
63 | if (shouldSkipIntegrationTest()) {
64 | return;
65 | }
66 |
67 | // This connection must be available if we didn't skip
68 | if (!connection) {
69 | throw new Error(
70 | 'Connection should be available when test is not skipped',
71 | );
72 | }
73 |
74 | // Use a non-existent repository name
75 | const nonExistentRepoName = 'non-existent-repo-' + Date.now();
76 |
77 | // Act & Assert - should throw an error for non-existent repo
78 | await expect(
79 | getRepository(connection, projectName, nonExistentRepoName),
80 | ).rejects.toThrow(/not found|Failed to get repository/);
81 | });
82 | });
83 |
```
--------------------------------------------------------------------------------
/src/features/repositories/list-repositories/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { listRepositories } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 | import { ListRepositoriesOptions } from '../types';
8 |
9 | describe('listRepositories integration', () => {
10 | let connection: WebApi | null = null;
11 | let projectName: string;
12 |
13 | beforeAll(async () => {
14 | // Get a real connection using environment variables
15 | connection = await getTestConnection();
16 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
17 | });
18 |
19 | test('should list repositories in a project', async () => {
20 | // Skip if no connection is available
21 | if (shouldSkipIntegrationTest()) {
22 | return;
23 | }
24 |
25 | // This connection must be available if we didn't skip
26 | if (!connection) {
27 | throw new Error(
28 | 'Connection should be available when test is not skipped',
29 | );
30 | }
31 |
32 | const options: ListRepositoriesOptions = {
33 | projectId: projectName,
34 | };
35 |
36 | // Act - make an actual API call to Azure DevOps
37 | const result = await listRepositories(connection, options);
38 |
39 | // Assert on the actual response
40 | expect(result).toBeDefined();
41 | expect(Array.isArray(result)).toBe(true);
42 |
43 | // Check structure of returned items (even if empty)
44 | if (result.length > 0) {
45 | const firstRepo = result[0];
46 | expect(firstRepo.id).toBeDefined();
47 | expect(firstRepo.name).toBeDefined();
48 | expect(firstRepo.project).toBeDefined();
49 |
50 | if (firstRepo.project) {
51 | expect(firstRepo.project.name).toBe(projectName);
52 | }
53 | }
54 | });
55 |
56 | test('should include links when option is specified', async () => {
57 | // Skip if no connection is available
58 | if (shouldSkipIntegrationTest()) {
59 | return;
60 | }
61 |
62 | // This connection must be available if we didn't skip
63 | if (!connection) {
64 | throw new Error(
65 | 'Connection should be available when test is not skipped',
66 | );
67 | }
68 |
69 | const options: ListRepositoriesOptions = {
70 | projectId: projectName,
71 | includeLinks: true,
72 | };
73 |
74 | // Act - make an actual API call to Azure DevOps
75 | const result = await listRepositories(connection, options);
76 |
77 | // Assert on the actual response
78 | expect(result).toBeDefined();
79 | expect(Array.isArray(result)).toBe(true);
80 |
81 | // Verify links are included, if repositories exist
82 | if (result.length > 0) {
83 | const firstRepo = result[0];
84 | expect(firstRepo._links).toBeDefined();
85 | }
86 | });
87 | });
88 |
```
--------------------------------------------------------------------------------
/src/features/projects/list-projects/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { listProjects } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 | import { ListProjectsOptions } from '../types';
8 |
9 | describe('listProjects integration', () => {
10 | let connection: WebApi | null = null;
11 |
12 | beforeAll(async () => {
13 | // Get a real connection using environment variables
14 | connection = await getTestConnection();
15 | });
16 |
17 | test('should list projects in the organization', async () => {
18 | // Skip if no connection is available
19 | if (shouldSkipIntegrationTest()) {
20 | return;
21 | }
22 |
23 | // This connection must be available if we didn't skip
24 | if (!connection) {
25 | throw new Error(
26 | 'Connection should be available when test is not skipped',
27 | );
28 | }
29 |
30 | // Act - make an actual API call to Azure DevOps
31 | const result = await listProjects(connection);
32 |
33 | // Assert on the actual response
34 | expect(result).toBeDefined();
35 | expect(Array.isArray(result)).toBe(true);
36 |
37 | // Check structure of returned items (even if empty)
38 | if (result.length > 0) {
39 | const firstProject = result[0];
40 | expect(firstProject.id).toBeDefined();
41 | expect(firstProject.name).toBeDefined();
42 | expect(firstProject.url).toBeDefined();
43 | expect(firstProject.state).toBeDefined();
44 | }
45 | });
46 |
47 | test('should apply pagination options', async () => {
48 | // Skip if no connection is available
49 | if (shouldSkipIntegrationTest()) {
50 | return;
51 | }
52 |
53 | // This connection must be available if we didn't skip
54 | if (!connection) {
55 | throw new Error(
56 | 'Connection should be available when test is not skipped',
57 | );
58 | }
59 |
60 | const options: ListProjectsOptions = {
61 | top: 2, // Only get up to 2 projects
62 | };
63 |
64 | // Act - make an actual API call to Azure DevOps
65 | const result = await listProjects(connection, options);
66 |
67 | // Assert on the actual response
68 | expect(result).toBeDefined();
69 | expect(Array.isArray(result)).toBe(true);
70 | expect(result.length).toBeLessThanOrEqual(2);
71 |
72 | // If we have projects, check for correct limit
73 | if (result.length > 0) {
74 | // Get all projects to compare
75 | const allProjects = await listProjects(connection);
76 |
77 | // If we have more than 2 total projects, pagination should have limited results
78 | if (allProjects.length > 2) {
79 | expect(result.length).toBe(2);
80 | expect(result.length).toBeLessThan(allProjects.length);
81 | }
82 | }
83 | });
84 | });
85 |
```
--------------------------------------------------------------------------------
/src/features/users/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
3 | import { isUsersRequest, handleUsersRequest } from './index';
4 | import { getMe } from './get-me';
5 |
6 | // Mock the imported modules
7 | jest.mock('./get-me', () => ({
8 | getMe: jest.fn(),
9 | }));
10 |
11 | describe('Users Request Handlers', () => {
12 | const mockConnection = {} as WebApi;
13 |
14 | describe('isUsersRequest', () => {
15 | it('should return true for users requests', () => {
16 | const request = {
17 | params: { name: 'get_me', arguments: {} },
18 | method: 'tools/call',
19 | } as CallToolRequest;
20 | expect(isUsersRequest(request)).toBe(true);
21 | });
22 |
23 | it('should return false for non-users requests', () => {
24 | const request = {
25 | params: { name: 'list_projects', arguments: {} },
26 | method: 'tools/call',
27 | } as CallToolRequest;
28 | expect(isUsersRequest(request)).toBe(false);
29 | });
30 | });
31 |
32 | describe('handleUsersRequest', () => {
33 | it('should handle get_me request', async () => {
34 | const mockUserProfile = {
35 | id: 'user-id-123',
36 | displayName: 'Test User',
37 | email: '[email protected]',
38 | };
39 | (getMe as jest.Mock).mockResolvedValue(mockUserProfile);
40 |
41 | const request = {
42 | params: {
43 | name: 'get_me',
44 | arguments: {},
45 | },
46 | method: 'tools/call',
47 | } as CallToolRequest;
48 |
49 | const response = await handleUsersRequest(mockConnection, request);
50 | expect(response.content).toHaveLength(1);
51 | expect(JSON.parse(response.content[0].text as string)).toEqual(
52 | mockUserProfile,
53 | );
54 | expect(getMe).toHaveBeenCalledWith(mockConnection);
55 | });
56 |
57 | it('should throw error for unknown tool', async () => {
58 | const request = {
59 | params: {
60 | name: 'unknown_tool',
61 | arguments: {},
62 | },
63 | method: 'tools/call',
64 | } as CallToolRequest;
65 |
66 | await expect(handleUsersRequest(mockConnection, request)).rejects.toThrow(
67 | 'Unknown users tool',
68 | );
69 | });
70 |
71 | it('should propagate errors from user functions', async () => {
72 | const mockError = new Error('Test error');
73 | (getMe as jest.Mock).mockRejectedValue(mockError);
74 |
75 | const request = {
76 | params: {
77 | name: 'get_me',
78 | arguments: {},
79 | },
80 | method: 'tools/call',
81 | } as CallToolRequest;
82 |
83 | await expect(handleUsersRequest(mockConnection, request)).rejects.toThrow(
84 | mockError,
85 | );
86 | });
87 | });
88 | });
89 |
```
--------------------------------------------------------------------------------
/src/features/repositories/get-repository-tree/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | GitVersionType,
4 | VersionControlRecursionType,
5 | GitObjectType,
6 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
7 | import { AzureDevOpsError } from '../../../shared/errors';
8 | import {
9 | GetRepositoryTreeOptions,
10 | RepositoryTreeItem,
11 | RepositoryTreeResponse,
12 | } from '../types';
13 |
14 | /**
15 | * Get tree view of files/directories in a repository starting at an optional path
16 | */
17 | export async function getRepositoryTree(
18 | connection: WebApi,
19 | options: GetRepositoryTreeOptions,
20 | ): Promise<RepositoryTreeResponse> {
21 | try {
22 | const gitApi = await connection.getGitApi();
23 |
24 | const repository = await gitApi.getRepository(
25 | options.repositoryId,
26 | options.projectId,
27 | );
28 | if (!repository || !repository.id) {
29 | throw new AzureDevOpsError(
30 | `Repository '${options.repositoryId}' not found in project '${options.projectId}'`,
31 | );
32 | }
33 |
34 | const defaultBranch = repository.defaultBranch;
35 | if (!defaultBranch) {
36 | throw new AzureDevOpsError('Default branch not found');
37 | }
38 | const branchRef = defaultBranch.replace('refs/heads/', '');
39 |
40 | const rootPath = options.path ?? '/';
41 | const items = await gitApi.getItems(
42 | repository.id,
43 | options.projectId,
44 | rootPath,
45 | VersionControlRecursionType.Full,
46 | true,
47 | false,
48 | false,
49 | false,
50 | {
51 | version: branchRef,
52 | versionType: GitVersionType.Branch,
53 | },
54 | );
55 |
56 | const treeItems: RepositoryTreeItem[] = [];
57 | const stats = { directories: 0, files: 0 };
58 |
59 | for (const item of items) {
60 | const path = item.path || '';
61 | if (path === rootPath || item.gitObjectType === GitObjectType.Bad) {
62 | continue;
63 | }
64 | const relative =
65 | rootPath === '/'
66 | ? path.replace(/^\//, '')
67 | : path.slice(rootPath.length + 1);
68 | const level = relative.split('/').length;
69 | if (options.depth && options.depth > 0 && level > options.depth) {
70 | continue;
71 | }
72 | const isFolder = !!item.isFolder;
73 | treeItems.push({
74 | name: relative.split('/').pop() || '',
75 | path,
76 | isFolder,
77 | level,
78 | });
79 | if (isFolder) stats.directories++;
80 | else stats.files++;
81 | }
82 |
83 | return {
84 | name: repository.name || options.repositoryId,
85 | tree: treeItems,
86 | stats,
87 | };
88 | } catch (error) {
89 | if (error instanceof AzureDevOpsError) {
90 | throw error;
91 | }
92 | throw new Error(
93 | `Failed to get repository tree: ${error instanceof Error ? error.message : String(error)}`,
94 | );
95 | }
96 | }
97 |
```
--------------------------------------------------------------------------------
/src/features/repositories/get-repository-details/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces';
3 | import {
4 | AzureDevOpsResourceNotFoundError,
5 | AzureDevOpsError,
6 | } from '../../../shared/errors';
7 | import { GetRepositoryDetailsOptions, RepositoryDetails } from '../types';
8 |
9 | /**
10 | * Get detailed information about a repository
11 | *
12 | * @param connection The Azure DevOps WebApi connection
13 | * @param options Options for getting repository details
14 | * @returns The repository details including optional statistics and refs
15 | * @throws {AzureDevOpsResourceNotFoundError} If the repository is not found
16 | */
17 | export async function getRepositoryDetails(
18 | connection: WebApi,
19 | options: GetRepositoryDetailsOptions,
20 | ): Promise<RepositoryDetails> {
21 | try {
22 | const gitApi = await connection.getGitApi();
23 |
24 | // Get the basic repository information
25 | const repository = await gitApi.getRepository(
26 | options.repositoryId,
27 | options.projectId,
28 | );
29 |
30 | if (!repository) {
31 | throw new AzureDevOpsResourceNotFoundError(
32 | `Repository '${options.repositoryId}' not found in project '${options.projectId}'`,
33 | );
34 | }
35 |
36 | // Initialize the response object
37 | const response: RepositoryDetails = {
38 | repository,
39 | };
40 |
41 | // Get branch statistics if requested
42 | if (options.includeStatistics) {
43 | let baseVersionDescriptor = undefined;
44 |
45 | // If a specific branch name is provided, create a version descriptor for it
46 | if (options.branchName) {
47 | baseVersionDescriptor = {
48 | version: options.branchName,
49 | versionType: GitVersionType.Branch,
50 | };
51 | }
52 |
53 | const branchStats = await gitApi.getBranches(
54 | repository.id || '',
55 | options.projectId,
56 | baseVersionDescriptor,
57 | );
58 |
59 | response.statistics = {
60 | branches: branchStats || [],
61 | };
62 | }
63 |
64 | // Get repository refs if requested
65 | if (options.includeRefs) {
66 | const filter = options.refFilter || undefined;
67 | const refs = await gitApi.getRefs(
68 | repository.id || '',
69 | options.projectId,
70 | filter,
71 | );
72 |
73 | if (refs) {
74 | response.refs = {
75 | value: refs,
76 | count: refs.length,
77 | };
78 | } else {
79 | response.refs = {
80 | value: [],
81 | count: 0,
82 | };
83 | }
84 | }
85 |
86 | return response;
87 | } catch (error) {
88 | if (error instanceof AzureDevOpsError) {
89 | throw error;
90 | }
91 | throw new Error(
92 | `Failed to get repository details: ${error instanceof Error ? error.message : String(error)}`,
93 | );
94 | }
95 | }
96 |
```
--------------------------------------------------------------------------------
/src/shared/enums/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | CommentThreadStatus,
3 | CommentType,
4 | GitVersionType,
5 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
6 | import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces';
7 |
8 | /**
9 | * Generic enum mapper that creates bidirectional mappings between strings and numeric enums
10 | */
11 | function createEnumMapper(
12 | mappings: Record<string, number>,
13 | defaultStringValue = 'unknown',
14 | ) {
15 | // Create reverse mapping from enum values to strings
16 | const reverseMap = Object.entries(mappings).reduce(
17 | (acc, [key, value]) => {
18 | acc[value] = key;
19 | return acc;
20 | },
21 | {} as Record<number, string>,
22 | );
23 |
24 | return {
25 | toEnum: (value: string): number | undefined => {
26 | const lowerValue = value.toLowerCase();
27 | return mappings[lowerValue];
28 | },
29 | toString: (value: number): string => {
30 | return reverseMap[value] ?? defaultStringValue;
31 | },
32 | };
33 | }
34 |
35 | /**
36 | * CommentThreadStatus enum mappings
37 | */
38 | export const commentThreadStatusMapper = createEnumMapper({
39 | unknown: CommentThreadStatus.Unknown,
40 | active: CommentThreadStatus.Active,
41 | fixed: CommentThreadStatus.Fixed,
42 | wontfix: CommentThreadStatus.WontFix,
43 | closed: CommentThreadStatus.Closed,
44 | bydesign: CommentThreadStatus.ByDesign,
45 | pending: CommentThreadStatus.Pending,
46 | });
47 |
48 | /**
49 | * CommentType enum mappings
50 | */
51 | export const commentTypeMapper = createEnumMapper({
52 | unknown: CommentType.Unknown,
53 | text: CommentType.Text,
54 | codechange: CommentType.CodeChange,
55 | system: CommentType.System,
56 | });
57 |
58 | /**
59 | * PullRequestStatus enum mappings
60 | */
61 | export const pullRequestStatusMapper = createEnumMapper({
62 | active: PullRequestStatus.Active,
63 | abandoned: PullRequestStatus.Abandoned,
64 | completed: PullRequestStatus.Completed,
65 | });
66 |
67 | /**
68 | * GitVersionType enum mappings
69 | */
70 | export const gitVersionTypeMapper = createEnumMapper({
71 | branch: GitVersionType.Branch,
72 | commit: GitVersionType.Commit,
73 | tag: GitVersionType.Tag,
74 | });
75 |
76 | /**
77 | * Transform comment thread status from numeric to string
78 | */
79 | export function transformCommentThreadStatus(
80 | status?: number,
81 | ): string | undefined {
82 | return status !== undefined
83 | ? commentThreadStatusMapper.toString(status)
84 | : undefined;
85 | }
86 |
87 | /**
88 | * Transform comment type from numeric to string
89 | */
90 | export function transformCommentType(type?: number): string | undefined {
91 | return type !== undefined ? commentTypeMapper.toString(type) : undefined;
92 | }
93 |
94 | /**
95 | * Transform pull request status from numeric to string
96 | */
97 | export function transformPullRequestStatus(
98 | status?: number,
99 | ): string | undefined {
100 | return status !== undefined
101 | ? pullRequestStatusMapper.toString(status)
102 | : undefined;
103 | }
104 |
```
--------------------------------------------------------------------------------
/src/features/projects/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Re-export schemas and types
2 | export * from './schemas';
3 | export * from './types';
4 |
5 | // Re-export features
6 | export * from './get-project';
7 | export * from './get-project-details';
8 | export * from './list-projects';
9 |
10 | // Export tool definitions
11 | export * from './tool-definitions';
12 |
13 | // New exports for request handling
14 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
15 | import { WebApi } from 'azure-devops-node-api';
16 | import {
17 | RequestIdentifier,
18 | RequestHandler,
19 | } from '../../shared/types/request-handler';
20 | import { defaultProject } from '../../utils/environment';
21 | import {
22 | GetProjectSchema,
23 | GetProjectDetailsSchema,
24 | ListProjectsSchema,
25 | getProject,
26 | getProjectDetails,
27 | listProjects,
28 | } from './';
29 |
30 | /**
31 | * Checks if the request is for the projects feature
32 | */
33 | export const isProjectsRequest: RequestIdentifier = (
34 | request: CallToolRequest,
35 | ): boolean => {
36 | const toolName = request.params.name;
37 | return ['list_projects', 'get_project', 'get_project_details'].includes(
38 | toolName,
39 | );
40 | };
41 |
42 | /**
43 | * Handles projects feature requests
44 | */
45 | export const handleProjectsRequest: RequestHandler = async (
46 | connection: WebApi,
47 | request: CallToolRequest,
48 | ): Promise<{ content: Array<{ type: string; text: string }> }> => {
49 | switch (request.params.name) {
50 | case 'list_projects': {
51 | const args = ListProjectsSchema.parse(request.params.arguments);
52 | const result = await listProjects(connection, {
53 | stateFilter: args.stateFilter,
54 | top: args.top,
55 | skip: args.skip,
56 | continuationToken: args.continuationToken,
57 | });
58 | return {
59 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
60 | };
61 | }
62 | case 'get_project': {
63 | const args = GetProjectSchema.parse(request.params.arguments);
64 | const result = await getProject(
65 | connection,
66 | args.projectId ?? defaultProject,
67 | );
68 | return {
69 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
70 | };
71 | }
72 | case 'get_project_details': {
73 | const args = GetProjectDetailsSchema.parse(request.params.arguments);
74 | const result = await getProjectDetails(connection, {
75 | projectId: args.projectId ?? defaultProject,
76 | includeProcess: args.includeProcess,
77 | includeWorkItemTypes: args.includeWorkItemTypes,
78 | includeFields: args.includeFields,
79 | includeTeams: args.includeTeams,
80 | expandTeamIdentity: args.expandTeamIdentity,
81 | });
82 | return {
83 | content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
84 | };
85 | }
86 | default:
87 | throw new Error(`Unknown projects tool: ${request.params.name}`);
88 | }
89 | };
90 |
```
--------------------------------------------------------------------------------
/src/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { normalizeAuthMethod } from './index';
2 | import { AuthenticationMethod } from './shared/auth/auth-factory';
3 |
4 | describe('index', () => {
5 | describe('normalizeAuthMethod', () => {
6 | it('should return AzureIdentity when authMethodStr is undefined', () => {
7 | // Arrange
8 | const authMethodStr = undefined;
9 |
10 | // Act
11 | const result = normalizeAuthMethod(authMethodStr);
12 |
13 | // Assert
14 | expect(result).toBe(AuthenticationMethod.AzureIdentity);
15 | });
16 |
17 | it('should return AzureIdentity when authMethodStr is empty', () => {
18 | // Arrange
19 | const authMethodStr = '';
20 |
21 | // Act
22 | const result = normalizeAuthMethod(authMethodStr);
23 |
24 | // Assert
25 | expect(result).toBe(AuthenticationMethod.AzureIdentity);
26 | });
27 |
28 | it('should handle PersonalAccessToken case-insensitively', () => {
29 | // Arrange
30 | const variations = ['pat', 'PAT', 'Pat', 'pAt', 'paT'];
31 |
32 | // Act & Assert
33 | variations.forEach((variant) => {
34 | expect(normalizeAuthMethod(variant)).toBe(
35 | AuthenticationMethod.PersonalAccessToken,
36 | );
37 | });
38 | });
39 |
40 | it('should handle AzureIdentity case-insensitively', () => {
41 | // Arrange
42 | const variations = [
43 | 'azure-identity',
44 | 'AZURE-IDENTITY',
45 | 'Azure-Identity',
46 | 'azure-Identity',
47 | 'Azure-identity',
48 | ];
49 |
50 | // Act & Assert
51 | variations.forEach((variant) => {
52 | expect(normalizeAuthMethod(variant)).toBe(
53 | AuthenticationMethod.AzureIdentity,
54 | );
55 | });
56 | });
57 |
58 | it('should handle AzureCli case-insensitively', () => {
59 | // Arrange
60 | const variations = [
61 | 'azure-cli',
62 | 'AZURE-CLI',
63 | 'Azure-Cli',
64 | 'azure-Cli',
65 | 'Azure-cli',
66 | ];
67 |
68 | // Act & Assert
69 | variations.forEach((variant) => {
70 | expect(normalizeAuthMethod(variant)).toBe(
71 | AuthenticationMethod.AzureCli,
72 | );
73 | });
74 | });
75 |
76 | it('should return AzureIdentity for unrecognized values', () => {
77 | // Arrange
78 | const unrecognized = [
79 | 'unknown',
80 | 'azureCli', // no hyphen
81 | 'azureIdentity', // no hyphen
82 | 'personal-access-token', // not matching enum value
83 | 'cli',
84 | 'identity',
85 | ];
86 |
87 | // Act & Assert (mute stderr for warning messages)
88 | const originalStderrWrite = process.stderr.write;
89 | process.stderr.write = jest.fn();
90 |
91 | try {
92 | unrecognized.forEach((value) => {
93 | expect(normalizeAuthMethod(value)).toBe(
94 | AuthenticationMethod.AzureIdentity,
95 | );
96 | });
97 | } finally {
98 | process.stderr.write = originalStderrWrite;
99 | }
100 | });
101 | });
102 | });
103 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/download-pipeline-artifact/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Readable } from 'stream';
2 | import { WebApi } from 'azure-devops-node-api';
3 | import { BuildArtifact } from 'azure-devops-node-api/interfaces/BuildInterfaces';
4 | import { downloadPipelineArtifact } from './feature';
5 | import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors';
6 |
7 | describe('downloadPipelineArtifact', () => {
8 | const projectId = 'GHQ_B2B_Delta';
9 | const runId = 13590799;
10 |
11 | const getArtifacts = jest.fn();
12 | const getItem = jest.fn();
13 | const getBuildApi = jest.fn().mockResolvedValue({ getArtifacts });
14 | const getFileContainerApi = jest.fn().mockResolvedValue({ getItem });
15 | const getPipelinesApi = jest.fn();
16 |
17 | const connection = {
18 | getBuildApi,
19 | getFileContainerApi,
20 | getPipelinesApi,
21 | } as unknown as WebApi;
22 |
23 | const containerArtifact: BuildArtifact = {
24 | name: 'embedding-metrics',
25 | source: 'source',
26 | resource: {
27 | type: 'Container',
28 | data: '#/39106000/embedding-metrics',
29 | downloadUrl: 'https://example.com/container.zip',
30 | },
31 | };
32 |
33 | beforeEach(() => {
34 | jest.resetAllMocks();
35 | getBuildApi.mockResolvedValue({ getArtifacts });
36 | getFileContainerApi.mockResolvedValue({ getItem });
37 | getArtifacts.mockResolvedValue([containerArtifact]);
38 | });
39 |
40 | it('downloads content from container artifacts using fallback paths', async () => {
41 | const streamContent = Readable.from(['{"status":"ok"}']);
42 |
43 | getItem.mockImplementation(
44 | async (
45 | _containerId: number,
46 | _scope: string | undefined,
47 | itemPath: string,
48 | ) => {
49 | if (itemPath === 'embedding-metrics/embedding_metrics.json') {
50 | return { statusCode: 200, result: streamContent };
51 | }
52 |
53 | return { statusCode: 404, result: undefined };
54 | },
55 | );
56 |
57 | const result = await downloadPipelineArtifact(connection, {
58 | projectId,
59 | runId,
60 | artifactPath: 'embedding-metrics/embedding_metrics.json',
61 | });
62 |
63 | expect(result).toEqual({
64 | artifact: 'embedding-metrics',
65 | path: 'embedding-metrics/embedding_metrics.json',
66 | content: '{"status":"ok"}',
67 | });
68 |
69 | const attemptedPaths = getItem.mock.calls.map(([, , path]) => path);
70 | expect(attemptedPaths).toContain('embedding_metrics.json');
71 | expect(attemptedPaths).toContain(
72 | 'embedding-metrics/embedding_metrics.json',
73 | );
74 | });
75 |
76 | it('throws when the requested file is missing', async () => {
77 | getItem.mockResolvedValue({ statusCode: 404, result: undefined });
78 |
79 | await expect(
80 | downloadPipelineArtifact(connection, {
81 | projectId,
82 | runId,
83 | artifactPath: 'embedding-metrics/missing.json',
84 | }),
85 | ).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
86 | });
87 | });
88 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-changes/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getPullRequestChanges } from './feature';
2 | import { AzureDevOpsError } from '../../../shared/errors';
3 | import { Readable } from 'stream';
4 |
5 | describe('getPullRequestChanges unit', () => {
6 | test('should retrieve changes, evaluations, and patches', async () => {
7 | const mockGitApi = {
8 | getPullRequest: jest.fn().mockResolvedValue({
9 | sourceRefName: 'refs/heads/feature',
10 | targetRefName: 'refs/heads/main',
11 | }),
12 | getPullRequestIterations: jest.fn().mockResolvedValue([{ id: 1 }]),
13 | getPullRequestIterationChanges: jest.fn().mockResolvedValue({
14 | changeEntries: [
15 | {
16 | item: {
17 | path: '/file.txt',
18 | objectId: 'new',
19 | originalObjectId: 'old',
20 | },
21 | },
22 | ],
23 | }),
24 | getBlobContent: jest.fn().mockImplementation((_: string, sha: string) => {
25 | const content = sha === 'new' ? 'new content\n' : 'old content\n';
26 | const stream = new Readable();
27 | stream.push(content);
28 | stream.push(null);
29 | return Promise.resolve(stream);
30 | }),
31 | };
32 | const mockConnection: any = {
33 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
34 | getPolicyApi: jest.fn().mockResolvedValue({
35 | getPolicyEvaluations: jest.fn().mockResolvedValue([{ id: '1' }]),
36 | }),
37 | };
38 |
39 | const result = await getPullRequestChanges(mockConnection, {
40 | projectId: 'p',
41 | repositoryId: 'r',
42 | pullRequestId: 1,
43 | });
44 |
45 | expect(result.changes).toEqual({
46 | changeEntries: [
47 | {
48 | item: { path: '/file.txt', objectId: 'new', originalObjectId: 'old' },
49 | },
50 | ],
51 | });
52 | expect(result.evaluations).toHaveLength(1);
53 | expect(result.files).toHaveLength(1);
54 | expect(result.files[0].path).toBe('/file.txt');
55 | expect(result.files[0].patch).toContain('-old content');
56 | expect(result.files[0].patch).toContain('+new content');
57 | expect(result.sourceRefName).toBe('refs/heads/feature');
58 | expect(result.targetRefName).toBe('refs/heads/main');
59 | expect(mockGitApi.getPullRequest).toHaveBeenCalledWith('r', 1, 'p');
60 | });
61 |
62 | test('should throw when no iterations found', async () => {
63 | const mockConnection: any = {
64 | getGitApi: jest.fn().mockResolvedValue({
65 | getPullRequest: jest.fn().mockResolvedValue({
66 | sourceRefName: 'refs/heads/source',
67 | targetRefName: 'refs/heads/target',
68 | }),
69 | getPullRequestIterations: jest.fn().mockResolvedValue([]),
70 | }),
71 | };
72 |
73 | await expect(
74 | getPullRequestChanges(mockConnection, {
75 | projectId: 'p',
76 | repositoryId: 'r',
77 | pullRequestId: 1,
78 | }),
79 | ).rejects.toThrow(AzureDevOpsError);
80 | });
81 | });
82 |
```
--------------------------------------------------------------------------------
/.github/workflows/update-skills.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Update Skills
2 |
3 | on:
4 | schedule:
5 | # Run weekly on Monday at 00:00 UTC
6 | - cron: '0 0 * * 1'
7 | workflow_dispatch: # Allow manual triggering
8 |
9 | permissions:
10 | contents: write
11 | pull-requests: write
12 |
13 | jobs:
14 | update-skills:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout repository
18 | uses: actions/checkout@v4
19 |
20 | - name: Clone anthropics/skills repository
21 | run: |
22 | git clone --depth 1 https://github.com/anthropics/skills.git /tmp/skills
23 |
24 | - name: Update skill-creator skill
25 | run: |
26 | # Remove old skill-creator
27 | rm -rf .github/skills/skill-creator
28 |
29 | # Copy new skill-creator
30 | mkdir -p .github/skills
31 | cp -r /tmp/skills/skill-creator .github/skills/
32 |
33 | - name: Update copilot-instructions.md
34 | run: |
35 | # Extract description from SKILL.md frontmatter
36 | DESCRIPTION=$(sed -n '/^description:/,/^[a-z-]*:/p' .github/skills/skill-creator/SKILL.md | grep '^description:' | sed 's/^description: //' | sed 's/^"//' | sed 's/"$//')
37 |
38 | # If description spans multiple lines, get the full description
39 | if [ -z "$DESCRIPTION" ]; then
40 | DESCRIPTION=$(awk '/^description:/{flag=1; sub(/^description: /, ""); print; next} flag{if(/^[a-z-]*:/){exit}; print}' .github/skills/skill-creator/SKILL.md | tr '\n' ' ' | sed 's/ */ /g')
41 | fi
42 |
43 | # Update the table in copilot-instructions.md
44 | # This preserves the header and updates the skill-creator row
45 | sed -i "/^| skill-creator |/c\| skill-creator | ${DESCRIPTION} | [.github/skills/skill-creator/SKILL.md](.github/skills/skill-creator/SKILL.md) |" copilot-instructions.md
46 |
47 | - name: Check for changes
48 | id: check_changes
49 | run: |
50 | git diff --quiet .github/skills copilot-instructions.md || echo "changes=true" >> $GITHUB_OUTPUT
51 |
52 | - name: Create Pull Request
53 | if: steps.check_changes.outputs.changes == 'true'
54 | uses: peter-evans/create-pull-request@v5
55 | with:
56 | token: ${{ secrets.GITHUB_TOKEN }}
57 | commit-message: 'chore(skills): update skill-creator from anthropics/skills'
58 | title: 'Update skill-creator skill'
59 | body: |
60 | This PR updates the skill-creator skill from the [anthropics/skills](https://github.com/anthropics/skills) repository.
61 |
62 | **Changes:**
63 | - Updated skill-creator skill files
64 | - Updated copilot-instructions.md metadata table
65 |
66 | This is an automated update triggered by the update-skills workflow.
67 | branch: update-skills
68 | delete-branch: true
69 | labels: |
70 | chore
71 | automated
72 |
```
--------------------------------------------------------------------------------
/.github/skills/azure-devops-rest-api/references/api_areas.md:
--------------------------------------------------------------------------------
```markdown
1 | # Azure DevOps API Areas Reference
2 |
3 | This document provides a comprehensive overview of all Azure DevOps REST API areas and their purposes.
4 |
5 | ## Core Platform Services
6 |
7 | ### account
8 | Organization and account management APIs for creating and managing Azure DevOps organizations.
9 |
10 | ### core
11 | Core platform APIs including:
12 | - Projects (create, update, list, delete)
13 | - Teams (create, update, list team members)
14 | - Processes (Agile, Scrum, CMMI, custom)
15 | - Connections and proxies
16 |
17 | ### graph
18 | Identity and access management:
19 | - Users and service principals
20 | - Groups and memberships
21 | - Descriptors and identity resolution
22 | - User entitlements
23 |
24 | ## Source Control & Git
25 |
26 | ### git
27 | Complete Git repository management:
28 | - Repositories (create, delete, list)
29 | - Branches and refs
30 | - Commits and pushes
31 | - Pull requests (create, update, review, merge)
32 | - Pull request threads and comments
33 | - Pull request iterations
34 | - Import requests
35 | - Cherry-picks and reverts
36 |
37 | ### tfvc
38 | Team Foundation Version Control (legacy)
39 |
40 | ## Build & Release
41 |
42 | ### build
43 | Build pipeline management:
44 | - Build definitions
45 | - Builds (queue, get, list)
46 | - Build artifacts
47 | - Build timeline and logs
48 |
49 | ### pipelines
50 | YAML pipeline management:
51 | - Pipeline definitions
52 | - Pipeline runs
53 | - Pipeline artifacts
54 | - Environments and approvals
55 |
56 | ### release
57 | Classic release pipeline management
58 |
59 | ### distributedTask
60 | Task and agent management
61 |
62 | ## Work Item Tracking
63 |
64 | ### wit (Work Item Tracking)
65 | Work item management:
66 | - Work items (create, update, delete, query)
67 | - Work item types and fields
68 | - Work item relations and links
69 | - Queries (WIQL)
70 |
71 | ### work
72 | Agile planning and boards:
73 | - Backlogs
74 | - Boards and board settings
75 | - Sprints and iterations
76 | - Team settings
77 |
78 | ## Testing
79 |
80 | ### testPlan
81 | Modern test management:
82 | - Test plans
83 | - Test suites
84 | - Test cases
85 |
86 | ### testResults
87 | Test execution and results:
88 | - Test runs
89 | - Test results
90 | - Code coverage
91 |
92 | ## Package Management
93 |
94 | ### artifacts
95 | Azure Artifacts feeds and packages
96 |
97 | ### artifactsPackageTypes
98 | Package type-specific APIs (NuGet, npm, Maven, Python)
99 |
100 | ## Extension & Integration
101 |
102 | ### extensionManagement
103 | Extension marketplace and installation
104 |
105 | ### serviceEndpoint
106 | Service connections
107 |
108 | ### hooks
109 | Service hooks and webhooks
110 |
111 | ## Security & Compliance
112 |
113 | ### security
114 | Access control and permissions
115 |
116 | ### policy
117 | Branch policies and quality gates
118 |
119 | ### audit
120 | Audit logging and compliance
121 |
122 | ## Notification & Communication
123 |
124 | ### notification
125 | Notification settings and subscriptions
126 |
127 | ### wiki
128 | Wiki pages and content
129 |
130 | ## Quick Reference
131 |
132 | Most commonly used APIs:
133 | - **Projects**: `core`
134 | - **Repos**: `git`
135 | - **Work Items**: `wit`
136 | - **Boards**: `work`
137 | - **Pipelines**: `pipelines` (YAML) or `build` (classic)
138 | - **Pull Requests**: `git`
139 | - **Test Management**: `testPlan`, `testResults`
140 | - **Packages**: `artifacts`
141 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/get-pipeline/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { getPipeline } from './feature';
3 | import { listPipelines } from '../list-pipelines/feature';
4 | import {
5 | getTestConnection,
6 | shouldSkipIntegrationTest,
7 | } from '../../../shared/test/test-helpers';
8 |
9 | describe('getPipeline integration', () => {
10 | let connection: WebApi | null = null;
11 | let projectId: string;
12 | let existingPipelineId: number | null = null;
13 |
14 | beforeAll(async () => {
15 | // Get a real connection using environment variables
16 | connection = await getTestConnection();
17 |
18 | // Get the project ID from environment variables, fallback to default
19 | projectId = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
20 |
21 | // Skip if no connection or project is available
22 | if (shouldSkipIntegrationTest() || !connection || !projectId) {
23 | return;
24 | }
25 |
26 | // Try to get an existing pipeline ID for testing
27 | try {
28 | const pipelines = await listPipelines(connection, { projectId });
29 | if (pipelines.length > 0) {
30 | existingPipelineId = pipelines[0].id ?? null;
31 | }
32 | } catch (error) {
33 | console.log('Could not find existing pipelines for testing:', error);
34 | }
35 | });
36 |
37 | test('should get a pipeline by ID', async () => {
38 | // Skip if no connection, project, or pipeline ID is available
39 | if (
40 | shouldSkipIntegrationTest() ||
41 | !connection ||
42 | !projectId ||
43 | !existingPipelineId
44 | ) {
45 | console.log(
46 | 'Skipping getPipeline integration test - no connection, project or existing pipeline available',
47 | );
48 | return;
49 | }
50 |
51 | // Act - make an API call to Azure DevOps
52 | const pipeline = await getPipeline(connection, {
53 | projectId,
54 | pipelineId: existingPipelineId,
55 | });
56 |
57 | // Assert
58 | expect(pipeline).toBeDefined();
59 | expect(pipeline.id).toBe(existingPipelineId);
60 | expect(pipeline.name).toBeDefined();
61 | expect(typeof pipeline.name).toBe('string');
62 | expect(pipeline.folder).toBeDefined();
63 | expect(pipeline.revision).toBeDefined();
64 | expect(pipeline.url).toBeDefined();
65 | expect(pipeline.url).toContain('_apis/pipelines');
66 | });
67 |
68 | test('should throw ResourceNotFoundError for non-existent pipeline', async () => {
69 | // Skip if no connection or project is available
70 | if (shouldSkipIntegrationTest() || !connection || !projectId) {
71 | console.log(
72 | 'Skipping getPipeline error test - no connection or project available',
73 | );
74 | return;
75 | }
76 |
77 | // Use a very high ID that is unlikely to exist
78 | const nonExistentPipelineId = 999999;
79 |
80 | // Act & Assert - should throw a not found error
81 | await expect(
82 | getPipeline(connection, {
83 | projectId,
84 | pipelineId: nonExistentPipelineId,
85 | }),
86 | ).rejects.toThrow(/not found/);
87 | });
88 | });
89 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/trigger-pipeline/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | AzureDevOpsError,
4 | AzureDevOpsAuthenticationError,
5 | AzureDevOpsResourceNotFoundError,
6 | } from '../../../shared/errors';
7 | import { defaultProject } from '../../../utils/environment';
8 | import { Run, TriggerPipelineOptions } from '../types';
9 |
10 | /**
11 | * Trigger a pipeline run
12 | *
13 | * @param connection The Azure DevOps WebApi connection
14 | * @param options Options for triggering a pipeline
15 | * @returns The run details
16 | */
17 | export async function triggerPipeline(
18 | connection: WebApi,
19 | options: TriggerPipelineOptions,
20 | ): Promise<Run> {
21 | try {
22 | const pipelinesApi = await connection.getPipelinesApi();
23 | const {
24 | projectId = defaultProject,
25 | pipelineId,
26 | branch,
27 | variables,
28 | templateParameters,
29 | stagesToSkip,
30 | } = options;
31 |
32 | // Prepare run parameters
33 | const runParameters: Record<string, unknown> = {};
34 |
35 | // Add variables
36 | if (variables) {
37 | runParameters.variables = variables;
38 | }
39 |
40 | // Add template parameters
41 | if (templateParameters) {
42 | runParameters.templateParameters = templateParameters;
43 | }
44 |
45 | // Add stages to skip
46 | if (stagesToSkip && stagesToSkip.length > 0) {
47 | runParameters.stagesToSkip = stagesToSkip;
48 | }
49 |
50 | // Prepare resources (including branch)
51 | const resources: Record<string, unknown> = branch
52 | ? { repositories: { self: { refName: `refs/heads/${branch}` } } }
53 | : {};
54 |
55 | // Add resources to run parameters if not empty
56 | if (Object.keys(resources).length > 0) {
57 | runParameters.resources = resources;
58 | }
59 | // Call pipeline API to run pipeline
60 | const result = await pipelinesApi.runPipeline(
61 | runParameters,
62 | projectId,
63 | pipelineId,
64 | );
65 |
66 | return result;
67 | } catch (error) {
68 | // Handle specific error types
69 | if (error instanceof AzureDevOpsError) {
70 | throw error;
71 | }
72 |
73 | // Check for specific error types and convert to appropriate Azure DevOps errors
74 | if (error instanceof Error) {
75 | if (
76 | error.message.includes('Authentication') ||
77 | error.message.includes('Unauthorized') ||
78 | error.message.includes('401')
79 | ) {
80 | throw new AzureDevOpsAuthenticationError(
81 | `Failed to authenticate: ${error.message}`,
82 | );
83 | }
84 |
85 | if (
86 | error.message.includes('not found') ||
87 | error.message.includes('does not exist') ||
88 | error.message.includes('404')
89 | ) {
90 | throw new AzureDevOpsResourceNotFoundError(
91 | `Pipeline or project not found: ${error.message}`,
92 | );
93 | }
94 | }
95 |
96 | // Otherwise, wrap it in a generic error
97 | throw new AzureDevOpsError(
98 | `Failed to trigger pipeline: ${error instanceof Error ? error.message : String(error)}`,
99 | );
100 | }
101 | }
102 |
```
--------------------------------------------------------------------------------
/src/features/wikis/create-wiki-page/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import * as azureDevOpsClient from '../../../clients/azure-devops';
3 | import { handleRequestError } from '../../../shared/errors/handle-request-error';
4 | import { CreateWikiPageSchema } from './schema';
5 | import { defaultOrg, defaultProject } from '../../../utils/environment';
6 |
7 | /**
8 | * Creates a new wiki page in Azure DevOps.
9 | * If a page already exists at the specified path, it will be updated.
10 | *
11 | * @param {z.infer<typeof CreateWikiPageSchema>} params - The parameters for creating the wiki page.
12 | * @returns {Promise<any>} A promise that resolves with the API response.
13 | */
14 | export const createWikiPage = async (
15 | params: z.infer<typeof CreateWikiPageSchema>,
16 | client?: {
17 | defaults?: { organizationId?: string; projectId?: string };
18 | put: (
19 | url: string,
20 | data: Record<string, unknown>,
21 | ) => Promise<{ data: unknown }>;
22 | }, // For testing purposes only
23 | ) => {
24 | try {
25 | const { organizationId, projectId, wikiId, pagePath, content, comment } =
26 | params;
27 |
28 | // For testing mode, use the client's defaults
29 | if (client && client.defaults) {
30 | const org = organizationId ?? client.defaults.organizationId;
31 | const project = projectId ?? client.defaults.projectId;
32 |
33 | if (!org) {
34 | throw new Error(
35 | 'Organization ID is not defined. Please provide it or set a default.',
36 | );
37 | }
38 |
39 | // This branch is for testing only
40 | const apiUrl = `${org}/${
41 | project ? `${project}/` : ''
42 | }_apis/wiki/wikis/${wikiId}/pages?path=${encodeURIComponent(
43 | pagePath ?? '/',
44 | )}&api-version=7.1-preview.1`;
45 |
46 | // Prepare the request body
47 | const requestBody: Record<string, unknown> = { content };
48 | if (comment) {
49 | requestBody.comment = comment;
50 | }
51 |
52 | // Make the API request
53 | const response = await client.put(apiUrl, requestBody);
54 | return response.data;
55 | } else {
56 | // Use default organization and project if not provided
57 | const org = organizationId ?? defaultOrg;
58 | const project = projectId ?? defaultProject;
59 |
60 | if (!org) {
61 | throw new Error(
62 | 'Organization ID is not defined. Please provide it or set a default.',
63 | );
64 | }
65 |
66 | // Create the client
67 | const wikiClient = await azureDevOpsClient.getWikiClient({
68 | organizationId: org,
69 | });
70 |
71 | // Prepare the wiki page content
72 | const wikiPageContent = {
73 | content,
74 | };
75 |
76 | // This is the real implementation
77 | return await wikiClient.updatePage(
78 | wikiPageContent,
79 | project,
80 | wikiId,
81 | pagePath ?? '/',
82 | {
83 | comment: comment ?? undefined,
84 | },
85 | );
86 | }
87 | } catch (error: unknown) {
88 | throw await handleRequestError(
89 | error,
90 | 'Failed to create or update wiki page',
91 | );
92 | }
93 | };
94 |
```
--------------------------------------------------------------------------------
/project-management/planning/tech-stack.md:
--------------------------------------------------------------------------------
```markdown
1 | ## Tech Stack Documentation
2 |
3 | ### Overview
4 |
5 | The tech stack for the Azure DevOps MCP server is tailored to ensure compatibility with the MCP, efficient interaction with Azure DevOps APIs, and a focus on security and scalability. It comprises a mix of programming languages, runtime environments, libraries, and development tools that streamline server development and operation.
6 |
7 | ### Programming Language and Runtime
8 |
9 | - **Typescript**: Selected for its type safety, which minimizes runtime errors and enhances code readability. It aligns seamlessly with the MCP Typescript SDK for easy integration.
10 | - **Node.js**: The runtime environment for executing Typescript, offering a non-blocking, event-driven architecture ideal for handling multiple API requests efficiently.
11 |
12 | ### Libraries and Dependencies
13 |
14 | - **MCP Typescript SDK**: The official SDK for MCP server development. It provides the `getMcpServer` function to define and run the server with minimal setup, managing socket connections and JSON-RPC messaging so developers can focus on tool logic.
15 | - **azure-devops-node-api**: A Node.js library that simplifies interaction with Azure DevOps REST APIs (e.g., Git, Work Item Tracking, Build). It supports Personal Access Token (PAT) authentication and offers a straightforward interface for common tasks.
16 | - **Axios**: A promise-based HTTP client for raw API requests, particularly useful for endpoints not covered by `azure-devops-node-api` (e.g., listing organizations or Search API). It also supports Azure Active Directory (AAD) token-based authentication.
17 | - **@azure/identity**: Facilitates AAD token acquisition for secure authentication with Azure DevOps resources when using AAD-based methods.
18 | - **dotenv**: A lightweight module for loading environment variables from a `.env` file, securely managing sensitive data like PATs and AAD credentials.
19 |
20 | ### Development Tools
21 |
22 | - **Visual Studio Code (VS Code)**: The recommended IDE, offering robust Typescript support, debugging tools, and integration with Git and Azure DevOps.
23 | - **npm**: The package manager for installing and managing project dependencies.
24 | - **ts-node**: Enables direct execution of Typescript files without precompilation, accelerating development and testing workflows.
25 |
26 | ### Testing and Quality Assurance
27 |
28 | - **Jest**: A widely-used testing framework for unit and integration tests, ensuring the reliability of tools and server functionality.
29 | - **ESLint**: A linter configured with Typescript-specific rules to maintain code quality and consistency.
30 | - **Prettier**: A code formatter to enforce a uniform style across the project.
31 |
32 | ### Version Control and CI/CD
33 |
34 | - **Git**: Used for version control, with repositories hosted on GitHub or Azure DevOps.
35 | - **GitHub Actions**: Automates continuous integration and deployment, including builds, tests, and releases.
36 |
37 | ---
38 |
```
--------------------------------------------------------------------------------
/create_branch.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # --- Configuration ---
4 | # Set the default remote name (usually 'origin')
5 | REMOTE_NAME="origin"
6 | # Set to 'true' if you want to force delete (-D) unmerged stale branches.
7 | # Set to 'false' to use safe delete (-d) which requires branches to be merged.
8 | FORCE_DELETE_STALE=false
9 | # ---------------------
10 |
11 | # Check if a branch name was provided as an argument
12 | if [ -z "$1" ]; then
13 | echo "Error: No branch name specified."
14 | echo "Usage: $0 <new-branch-name>"
15 | exit 1
16 | fi
17 |
18 | NEW_BRANCH_NAME="$1"
19 |
20 | # --- Pruning Section ---
21 | echo "--- Pruning stale branches ---"
22 |
23 | # 1. Update from remote and prune remote-tracking branches that no longer exist on the remote
24 | echo "Fetching updates from '$REMOTE_NAME' and pruning remote-tracking refs..."
25 | git fetch --prune "$REMOTE_NAME"
26 | echo "Fetch and prune complete."
27 | echo
28 |
29 | # 2. Identify and delete local branches whose upstream is gone
30 | echo "Checking for local branches tracking deleted remote branches..."
31 |
32 | # Get list of local branches marked as 'gone' relative to the specified remote
33 | # Use awk to correctly extract the branch name, handling the '*' for the current branch
34 | GONE_BRANCHES=$(git branch -vv | grep "\[$REMOTE_NAME/.*: gone\]" | awk '/^\*/ {print $2} ! /^\*/ {print $1}')
35 |
36 | if [ -z "$GONE_BRANCHES" ]; then
37 | echo "No stale local branches found to delete."
38 | else
39 | echo "Found stale local branches:"
40 | echo "$GONE_BRANCHES"
41 | echo
42 |
43 | DELETE_CMD="git branch -d"
44 | if [ "$FORCE_DELETE_STALE" = true ]; then
45 | echo "Attempting to force delete (-D) stale local branches..."
46 | DELETE_CMD="git branch -D"
47 | else
48 | echo "Attempting to safely delete (-d) stale local branches (will skip unmerged branches)..."
49 | fi
50 |
51 | # Loop through and delete each branch, handling potential errors
52 | echo "$GONE_BRANCHES" | while IFS= read -r branch; do
53 | # Check if the branch to be deleted is the current branch
54 | CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
55 | if [ "$branch" = "$CURRENT_BRANCH" ]; then
56 | echo "Skipping deletion of '$branch' because it is the current branch."
57 | continue
58 | fi
59 |
60 | echo "Deleting local branch '$branch'..."
61 | # Use the chosen delete command (-d or -D)
62 | $DELETE_CMD "$branch"
63 | done
64 | echo "Stale branch cleanup finished."
65 | fi
66 | echo "--- Pruning complete ---"
67 | echo
68 |
69 | # --- Branch Creation Section ---
70 | echo "Creating and checking out new branch: '$NEW_BRANCH_NAME'..."
71 | git checkout -b "$NEW_BRANCH_NAME"
72 |
73 | # Check if checkout was successful (it might fail if the branch already exists locally)
74 | if [ $? -ne 0 ]; then
75 | echo "Error: Failed to create or checkout branch '$NEW_BRANCH_NAME'."
76 | echo "It might already exist locally."
77 | exit 1
78 | fi
79 |
80 | echo ""
81 | echo "Successfully created and switched to branch '$NEW_BRANCH_NAME'."
82 | # Optional: Suggest pushing and setting upstream
83 | # echo "To push and set the upstream: git push -u $REMOTE_NAME $NEW_BRANCH_NAME"
84 |
85 | exit 0
```
--------------------------------------------------------------------------------
/src/features/search/search-wiki/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { searchWiki } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 |
8 | describe('searchWiki integration', () => {
9 | let connection: WebApi | null = null;
10 | let projectName: string;
11 |
12 | beforeAll(async () => {
13 | // Get a real connection using environment variables
14 | connection = await getTestConnection();
15 | projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
16 | });
17 |
18 | test('should search wiki content', async () => {
19 | // Skip if no connection is available
20 | if (shouldSkipIntegrationTest()) {
21 | return;
22 | }
23 |
24 | // This connection must be available if we didn't skip
25 | if (!connection) {
26 | throw new Error(
27 | 'Connection should be available when test is not skipped',
28 | );
29 | }
30 |
31 | // Search the wiki
32 | const result = await searchWiki(connection, {
33 | searchText: 'test',
34 | projectId: projectName,
35 | top: 10,
36 | });
37 |
38 | // Verify the result
39 | expect(result).toBeDefined();
40 | expect(result.count).toBeDefined();
41 | expect(Array.isArray(result.results)).toBe(true);
42 | if (result.results.length > 0) {
43 | expect(result.results[0].fileName).toBeDefined();
44 | expect(result.results[0].path).toBeDefined();
45 | expect(result.results[0].project).toBeDefined();
46 | }
47 | });
48 |
49 | test('should handle pagination correctly', async () => {
50 | // Skip if no connection is available
51 | if (shouldSkipIntegrationTest()) {
52 | return;
53 | }
54 |
55 | // This connection must be available if we didn't skip
56 | if (!connection) {
57 | throw new Error(
58 | 'Connection should be available when test is not skipped',
59 | );
60 | }
61 |
62 | // Get first page of results
63 | const page1 = await searchWiki(connection, {
64 | searchText: 'test', // Common word likely to have many results
65 | projectId: projectName,
66 | top: 5,
67 | skip: 0,
68 | });
69 |
70 | // Get second page of results
71 | const page2 = await searchWiki(connection, {
72 | searchText: 'test',
73 | projectId: projectName,
74 | top: 5,
75 | skip: 5,
76 | });
77 |
78 | // Verify pagination
79 | expect(page1.results).not.toEqual(page2.results);
80 | });
81 |
82 | test('should handle filters correctly', async () => {
83 | // Skip if no connection is available
84 | if (shouldSkipIntegrationTest()) {
85 | return;
86 | }
87 |
88 | // This connection must be available if we didn't skip
89 | if (!connection) {
90 | throw new Error(
91 | 'Connection should be available when test is not skipped',
92 | );
93 | }
94 |
95 | // This test is more of a smoke test since we can't guarantee specific projects
96 | const result = await searchWiki(connection, {
97 | searchText: 'test',
98 | filters: {
99 | Project: [projectName],
100 | },
101 | includeFacets: true,
102 | });
103 |
104 | expect(result).toBeDefined();
105 | expect(result.facets).toBeDefined();
106 | });
107 | });
108 |
```
--------------------------------------------------------------------------------
/src/features/wikis/create-wiki/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | AzureDevOpsError,
4 | AzureDevOpsValidationError,
5 | } from '../../../shared/errors';
6 | import { WikiType } from './schema';
7 | import { getWikiClient } from '../../../clients/azure-devops';
8 |
9 | /**
10 | * Options for creating a wiki
11 | */
12 | export interface CreateWikiOptions {
13 | /**
14 | * The ID or name of the organization
15 | * If not provided, the default organization will be used
16 | */
17 | organizationId?: string;
18 |
19 | /**
20 | * The ID or name of the project
21 | * If not provided, the default project will be used
22 | */
23 | projectId?: string;
24 |
25 | /**
26 | * The name of the new wiki
27 | */
28 | name: string;
29 |
30 | /**
31 | * Type of wiki to create (projectWiki or codeWiki)
32 | * Default is projectWiki
33 | */
34 | type?: WikiType;
35 |
36 | /**
37 | * The ID of the repository to associate with the wiki
38 | * Required when type is codeWiki
39 | */
40 | repositoryId?: string;
41 |
42 | /**
43 | * Folder path inside repository which is shown as Wiki
44 | * Only applicable for codeWiki type
45 | * Default is '/'
46 | */
47 | mappedPath?: string;
48 | }
49 |
50 | /**
51 | * Create a new wiki in Azure DevOps
52 | *
53 | * @param _connection The Azure DevOps WebApi connection (deprecated, kept for backward compatibility)
54 | * @param options Options for creating a wiki
55 | * @returns The created wiki
56 | * @throws {AzureDevOpsValidationError} When required parameters are missing
57 | * @throws {AzureDevOpsResourceNotFoundError} When the project or repository is not found
58 | * @throws {AzureDevOpsPermissionError} When the user does not have permission to create a wiki
59 | * @throws {AzureDevOpsError} When an error occurs while creating the wiki
60 | */
61 | export async function createWiki(
62 | _connection: WebApi,
63 | options: CreateWikiOptions,
64 | ) {
65 | try {
66 | const {
67 | name,
68 | projectId,
69 | type = WikiType.ProjectWiki,
70 | repositoryId,
71 | mappedPath = '/',
72 | } = options;
73 |
74 | // Validate repository ID for code wiki
75 | if (type === WikiType.CodeWiki && !repositoryId) {
76 | throw new AzureDevOpsValidationError(
77 | 'Repository ID is required for code wikis',
78 | );
79 | }
80 |
81 | // Get the Wiki client
82 | const wikiClient = await getWikiClient({
83 | organizationId: options.organizationId,
84 | });
85 |
86 | // Prepare the wiki creation parameters
87 | const wikiCreateParams = {
88 | name,
89 | projectId: projectId!,
90 | type,
91 | ...(type === WikiType.CodeWiki && {
92 | repositoryId,
93 | mappedPath,
94 | version: {
95 | version: 'main',
96 | versionType: 'branch' as const,
97 | },
98 | }),
99 | };
100 |
101 | // Create the wiki
102 | return await wikiClient.createWiki(projectId!, wikiCreateParams);
103 | } catch (error) {
104 | // Just rethrow if it's already one of our error types
105 | if (error instanceof AzureDevOpsError) {
106 | throw error;
107 | }
108 |
109 | // Otherwise wrap in AzureDevOpsError
110 | throw new AzureDevOpsError(
111 | `Failed to create wiki: ${error instanceof Error ? error.message : String(error)}`,
112 | );
113 | }
114 | }
115 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/get-pipeline-log/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | AzureDevOpsAuthenticationError,
4 | AzureDevOpsError,
5 | AzureDevOpsResourceNotFoundError,
6 | } from '../../../shared/errors';
7 | import { defaultProject } from '../../../utils/environment';
8 | import { GetPipelineLogOptions, PipelineLogContent } from '../types';
9 |
10 | const API_VERSION = '7.1';
11 |
12 | export async function getPipelineLog(
13 | connection: WebApi,
14 | options: GetPipelineLogOptions,
15 | ): Promise<PipelineLogContent> {
16 | try {
17 | const buildApi = await connection.getBuildApi();
18 | const projectId = options.projectId ?? defaultProject;
19 | const { runId, logId, format, startLine, endLine } = options;
20 |
21 | if (format === 'json') {
22 | const route = `${encodeURIComponent(projectId)}/_apis/build/builds/${runId}/logs/${logId}`;
23 | const baseUrl = connection.serverUrl.replace(/\/+$/, '');
24 | const url = new URL(`${route}`, `${baseUrl}/`);
25 | url.searchParams.set('api-version', API_VERSION);
26 | url.searchParams.set('format', 'json');
27 | if (typeof startLine === 'number') {
28 | url.searchParams.set('startLine', startLine.toString());
29 | }
30 | if (typeof endLine === 'number') {
31 | url.searchParams.set('endLine', endLine.toString());
32 | }
33 |
34 | const requestOptions = buildApi.createRequestOptions(
35 | 'application/json',
36 | API_VERSION,
37 | );
38 |
39 | const response = await buildApi.rest.get<PipelineLogContent | null>(
40 | url.toString(),
41 | requestOptions,
42 | );
43 |
44 | if (response.statusCode === 404 || response.result === null) {
45 | throw new AzureDevOpsResourceNotFoundError(
46 | `Log ${logId} not found for run ${runId} in project ${projectId}`,
47 | );
48 | }
49 |
50 | return response.result;
51 | }
52 |
53 | const lines = await buildApi.getBuildLogLines(
54 | projectId,
55 | runId,
56 | logId,
57 | startLine,
58 | endLine,
59 | );
60 |
61 | if (!lines) {
62 | throw new AzureDevOpsResourceNotFoundError(
63 | `Log ${logId} not found for run ${runId} in project ${projectId}`,
64 | );
65 | }
66 |
67 | return lines.join('\n');
68 | } catch (error) {
69 | if (error instanceof AzureDevOpsError) {
70 | throw error;
71 | }
72 |
73 | if (error instanceof Error) {
74 | const message = error.message.toLowerCase();
75 | if (
76 | message.includes('authentication') ||
77 | message.includes('unauthorized') ||
78 | message.includes('401')
79 | ) {
80 | throw new AzureDevOpsAuthenticationError(
81 | `Failed to authenticate: ${error.message}`,
82 | );
83 | }
84 |
85 | if (
86 | message.includes('not found') ||
87 | message.includes('does not exist') ||
88 | message.includes('404')
89 | ) {
90 | throw new AzureDevOpsResourceNotFoundError(
91 | `Pipeline log or project not found: ${error.message}`,
92 | );
93 | }
94 | }
95 |
96 | throw new AzureDevOpsError(
97 | `Failed to retrieve pipeline log: ${
98 | error instanceof Error ? error.message : String(error)
99 | }`,
100 | );
101 | }
102 | }
103 |
```
--------------------------------------------------------------------------------
/src/features/work-items/create-work-item/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { AzureDevOpsError } from '../../../shared/errors';
3 | import { CreateWorkItemOptions, WorkItem } from '../types';
4 |
5 | /**
6 | * Create a work item
7 | *
8 | * @param connection The Azure DevOps WebApi connection
9 | * @param projectId The ID or name of the project
10 | * @param workItemType The type of work item to create (e.g., "Task", "Bug", "User Story")
11 | * @param options Options for creating the work item
12 | * @returns The created work item
13 | */
14 | export async function createWorkItem(
15 | connection: WebApi,
16 | projectId: string,
17 | workItemType: string,
18 | options: CreateWorkItemOptions,
19 | ): Promise<WorkItem> {
20 | try {
21 | if (!options.title) {
22 | throw new Error('Title is required');
23 | }
24 |
25 | const witApi = await connection.getWorkItemTrackingApi();
26 |
27 | // Create the JSON patch document
28 | const document = [];
29 |
30 | // Add required fields
31 | document.push({
32 | op: 'add',
33 | path: '/fields/System.Title',
34 | value: options.title,
35 | });
36 |
37 | // Add optional fields if provided
38 | if (options.description) {
39 | document.push({
40 | op: 'add',
41 | path: '/fields/System.Description',
42 | value: options.description,
43 | });
44 | }
45 |
46 | if (options.assignedTo) {
47 | document.push({
48 | op: 'add',
49 | path: '/fields/System.AssignedTo',
50 | value: options.assignedTo,
51 | });
52 | }
53 |
54 | if (options.areaPath) {
55 | document.push({
56 | op: 'add',
57 | path: '/fields/System.AreaPath',
58 | value: options.areaPath,
59 | });
60 | }
61 |
62 | if (options.iterationPath) {
63 | document.push({
64 | op: 'add',
65 | path: '/fields/System.IterationPath',
66 | value: options.iterationPath,
67 | });
68 | }
69 |
70 | if (options.priority !== undefined) {
71 | document.push({
72 | op: 'add',
73 | path: '/fields/Microsoft.VSTS.Common.Priority',
74 | value: options.priority,
75 | });
76 | }
77 |
78 | // Add parent relationship if parentId is provided
79 | if (options.parentId) {
80 | document.push({
81 | op: 'add',
82 | path: '/relations/-',
83 | value: {
84 | rel: 'System.LinkTypes.Hierarchy-Reverse',
85 | url: `${connection.serverUrl}/_apis/wit/workItems/${options.parentId}`,
86 | },
87 | });
88 | }
89 |
90 | // Add any additional fields
91 | if (options.additionalFields) {
92 | for (const [key, value] of Object.entries(options.additionalFields)) {
93 | document.push({
94 | op: 'add',
95 | path: `/fields/${key}`,
96 | value: value,
97 | });
98 | }
99 | }
100 |
101 | // Create the work item
102 | const workItem = await witApi.createWorkItem(
103 | null,
104 | document,
105 | projectId,
106 | workItemType,
107 | );
108 |
109 | if (!workItem) {
110 | throw new Error('Failed to create work item');
111 | }
112 |
113 | return workItem;
114 | } catch (error) {
115 | if (error instanceof AzureDevOpsError) {
116 | throw error;
117 | }
118 | throw new Error(
119 | `Failed to create work item: ${error instanceof Error ? error.message : String(error)}`,
120 | );
121 | }
122 | }
123 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | GitPullRequest,
3 | Comment,
4 | GitPullRequestCommentThread,
5 | CommentPosition,
6 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
7 |
8 | export type PullRequest = GitPullRequest;
9 | export type PullRequestComment = Comment;
10 |
11 | /**
12 | * Extended Comment type with string enum values
13 | */
14 | export interface CommentWithStringEnums extends Omit<Comment, 'commentType'> {
15 | commentType?: string;
16 | filePath?: string;
17 | leftFileStart?: CommentPosition;
18 | leftFileEnd?: CommentPosition;
19 | rightFileStart?: CommentPosition;
20 | rightFileEnd?: CommentPosition;
21 | }
22 |
23 | /**
24 | * Extended GitPullRequestCommentThread type with string enum values
25 | */
26 | export interface CommentThreadWithStringEnums
27 | extends Omit<GitPullRequestCommentThread, 'status' | 'comments'> {
28 | status?: string;
29 | comments?: CommentWithStringEnums[];
30 | }
31 |
32 | /**
33 | * Response type for add comment operations
34 | */
35 | export interface AddCommentResponse {
36 | comment: CommentWithStringEnums;
37 | thread?: CommentThreadWithStringEnums;
38 | }
39 |
40 | /**
41 | * Options for creating a pull request
42 | */
43 | export interface CreatePullRequestOptions {
44 | title: string;
45 | description?: string;
46 | sourceRefName: string;
47 | targetRefName: string;
48 | reviewers?: string[];
49 | isDraft?: boolean;
50 | workItemRefs?: number[];
51 | tags?: string[];
52 | additionalProperties?: Record<string, string | number | boolean>;
53 | }
54 |
55 | /**
56 | * Options for listing pull requests
57 | */
58 | export interface ListPullRequestsOptions {
59 | projectId: string;
60 | repositoryId: string;
61 | status?: 'all' | 'active' | 'completed' | 'abandoned';
62 | creatorId?: string;
63 | reviewerId?: string;
64 | sourceRefName?: string;
65 | targetRefName?: string;
66 | top?: number;
67 | skip?: number;
68 | pullRequestId?: number;
69 | }
70 |
71 | /**
72 | * Options for getting pull request comments
73 | */
74 | export interface GetPullRequestCommentsOptions {
75 | projectId: string;
76 | repositoryId: string;
77 | pullRequestId: number;
78 | threadId?: number;
79 | includeDeleted?: boolean;
80 | top?: number;
81 | }
82 |
83 | /**
84 | * Options for adding a comment to a pull request
85 | */
86 | export interface AddPullRequestCommentOptions {
87 | projectId: string;
88 | repositoryId: string;
89 | pullRequestId: number;
90 | content: string;
91 | // For responding to an existing comment
92 | threadId?: number;
93 | parentCommentId?: number;
94 | // For file comments (new threads)
95 | filePath?: string;
96 | lineNumber?: number;
97 | // Additional options
98 | status?:
99 | | 'active'
100 | | 'fixed'
101 | | 'wontFix'
102 | | 'closed'
103 | | 'pending'
104 | | 'byDesign'
105 | | 'unknown';
106 | }
107 |
108 | /**
109 | * Options for updating a pull request
110 | */
111 | export interface UpdatePullRequestOptions {
112 | projectId: string;
113 | repositoryId: string;
114 | pullRequestId: number;
115 | title?: string;
116 | description?: string;
117 | status?: 'active' | 'abandoned' | 'completed';
118 | isDraft?: boolean;
119 | addWorkItemIds?: number[];
120 | removeWorkItemIds?: number[];
121 | addReviewers?: string[]; // Array of reviewer identifiers (email or ID)
122 | removeReviewers?: string[]; // Array of reviewer identifiers (email or ID)
123 | addTags?: string[];
124 | removeTags?: string[];
125 | additionalProperties?: Record<string, string | number | boolean>;
126 | }
127 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | // Re-export the Pipeline interface from the Azure DevOps API
2 | import {
3 | Pipeline,
4 | Run,
5 | } from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
6 |
7 | /**
8 | * Options for listing pipelines
9 | */
10 | export interface ListPipelinesOptions {
11 | projectId: string;
12 | orderBy?: string;
13 | top?: number;
14 | continuationToken?: string;
15 | }
16 |
17 | /**
18 | * Options for getting a pipeline
19 | */
20 | export interface GetPipelineOptions {
21 | projectId: string;
22 | organizationId?: string;
23 | pipelineId: number;
24 | pipelineVersion?: number;
25 | }
26 |
27 | /**
28 | * Options for triggering a pipeline
29 | */
30 | export interface TriggerPipelineOptions {
31 | projectId: string;
32 | pipelineId: number;
33 | branch?: string;
34 | variables?: Record<string, { value: string; isSecret?: boolean }>;
35 | templateParameters?: Record<string, string>;
36 | stagesToSkip?: string[];
37 | }
38 |
39 | /**
40 | * Options for listing runs of a pipeline
41 | */
42 | export interface ListPipelineRunsOptions {
43 | projectId: string;
44 | pipelineId: number;
45 | top?: number;
46 | continuationToken?: string;
47 | branch?: string;
48 | state?:
49 | | 'notStarted'
50 | | 'inProgress'
51 | | 'completed'
52 | | 'cancelling'
53 | | 'postponed';
54 | result?: 'succeeded' | 'partiallySucceeded' | 'failed' | 'canceled' | 'none';
55 | createdFrom?: string;
56 | createdTo?: string;
57 | orderBy?: 'createdDate desc' | 'createdDate asc';
58 | }
59 |
60 | /**
61 | * Result of listing pipeline runs
62 | */
63 | export interface ListPipelineRunsResult {
64 | runs: Run[];
65 | continuationToken?: string;
66 | }
67 |
68 | /**
69 | * Options for retrieving a single pipeline run
70 | */
71 | export interface GetPipelineRunOptions {
72 | projectId: string;
73 | runId: number;
74 | pipelineId?: number;
75 | }
76 |
77 | export interface PipelineArtifactItem {
78 | path: string;
79 | itemType: 'file' | 'folder';
80 | size?: number;
81 | }
82 |
83 | export interface PipelineRunArtifact {
84 | name: string;
85 | type?: string;
86 | source?: string;
87 | downloadUrl?: string;
88 | resourceUrl?: string;
89 | containerId?: number;
90 | rootPath?: string;
91 | signedContentUrl?: string;
92 | /** Sorted list of files/folders discovered inside the artifact */
93 | items?: PipelineArtifactItem[];
94 | /** Indicates the artifact has more items than were returned */
95 | itemsTruncated?: boolean;
96 | }
97 |
98 | export interface PipelineRunDetails extends Run {
99 | artifacts?: PipelineRunArtifact[];
100 | }
101 |
102 | export interface DownloadPipelineArtifactOptions {
103 | projectId: string;
104 | runId: number;
105 | artifactPath: string;
106 | pipelineId?: number;
107 | }
108 |
109 | export interface PipelineArtifactContent {
110 | artifact: string;
111 | path: string;
112 | content: string;
113 | }
114 |
115 | /**
116 | * Options for retrieving the timeline of a pipeline run
117 | */
118 | export interface GetPipelineTimelineOptions {
119 | projectId: string;
120 | runId: number;
121 | timelineId?: string;
122 | pipelineId?: number;
123 | state?: string | string[];
124 | result?: string | string[];
125 | }
126 |
127 | export type PipelineTimeline = Record<string, unknown>;
128 |
129 | /**
130 | * Options for retrieving a specific pipeline log
131 | */
132 | export interface GetPipelineLogOptions {
133 | projectId: string;
134 | runId: number;
135 | logId: number;
136 | pipelineId?: number;
137 | format?: 'plain' | 'json';
138 | startLine?: number;
139 | endLine?: number;
140 | }
141 |
142 | export type PipelineLogContent = unknown;
143 |
144 | export { Pipeline, Run };
145 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@tiberriver256/mcp-server-azure-devops",
3 | "version": "0.1.43",
4 | "description": "Azure DevOps reference server for the Model Context Protocol (MCP)",
5 | "main": "dist/index.js",
6 | "types": "dist/index.d.ts",
7 | "bin": {
8 | "mcp-server-azure-devops": "./dist/index.js"
9 | },
10 | "files": [
11 | "dist/",
12 | "docs/",
13 | "LICENSE",
14 | "README.md"
15 | ],
16 | "publishConfig": {
17 | "access": "public"
18 | },
19 | "config": {
20 | "commitizen": {
21 | "path": "./node_modules/cz-conventional-changelog"
22 | }
23 | },
24 | "lint-staged": {
25 | "*.ts": [
26 | "prettier --write",
27 | "eslint --fix"
28 | ]
29 | },
30 | "release-please": {
31 | "release-type": "node",
32 | "changelog-types": [
33 | {
34 | "type": "feat",
35 | "section": "Features",
36 | "hidden": false
37 | },
38 | {
39 | "type": "fix",
40 | "section": "Bug Fixes",
41 | "hidden": false
42 | },
43 | {
44 | "type": "chore",
45 | "section": "Miscellaneous",
46 | "hidden": false
47 | },
48 | {
49 | "type": "docs",
50 | "section": "Documentation",
51 | "hidden": false
52 | },
53 | {
54 | "type": "perf",
55 | "section": "Performance Improvements",
56 | "hidden": false
57 | },
58 | {
59 | "type": "refactor",
60 | "section": "Code Refactoring",
61 | "hidden": false
62 | }
63 | ]
64 | },
65 | "scripts": {
66 | "build": "tsc",
67 | "prepack": "npm run build && chmod +x dist/index.js",
68 | "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
69 | "start": "node dist/index.js",
70 | "inspector": "npm run build && npx @modelcontextprotocol/[email protected] node dist/index.js",
71 | "test": "npm run test:unit && npm run test:int && npm run test:e2e",
72 | "test:unit": "jest --config jest.unit.config.js",
73 | "test:int": "jest --config jest.int.config.js",
74 | "test:e2e": "jest --config jest.e2e.config.js",
75 | "test:watch": "jest --watch",
76 | "lint": "eslint . --ext .ts",
77 | "lint:fix": "eslint . --ext .ts --fix",
78 | "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
79 | "prepare": "husky install",
80 | "commit": "cz"
81 | },
82 | "keywords": [
83 | "azure-devops",
84 | "mcp",
85 | "ai",
86 | "automation"
87 | ],
88 | "author": "",
89 | "license": "MIT",
90 | "dependencies": {
91 | "@azure/identity": "^4.8.0",
92 | "@modelcontextprotocol/sdk": "^1.6.0",
93 | "axios": "^1.8.3",
94 | "azure-devops-node-api": "^13.0.0",
95 | "diff": "^8.0.2",
96 | "dotenv": "^16.3.1",
97 | "jszip": "^3.10.1",
98 | "minimatch": "^10.0.1",
99 | "zod": "^3.24.2",
100 | "zod-to-json-schema": "^3.24.5"
101 | },
102 | "devDependencies": {
103 | "@commitlint/cli": "^19.8.0",
104 | "@commitlint/config-conventional": "^19.8.0",
105 | "@types/diff": "^7.0.2",
106 | "@types/jest": "^29.5.0",
107 | "@types/node": "^20.0.0",
108 | "@typescript-eslint/eslint-plugin": "^8.27.0",
109 | "@typescript-eslint/parser": "^8.27.0",
110 | "commitizen": "^4.3.1",
111 | "cz-conventional-changelog": "^3.3.0",
112 | "eslint": "^8.0.0",
113 | "husky": "^8.0.3",
114 | "jest": "^29.0.0",
115 | "lint-staged": "^15.5.0",
116 | "prettier": "^3.0.0",
117 | "ts-jest": "^29.0.0",
118 | "ts-node-dev": "^2.0.0",
119 | "typescript": "^5.8.2"
120 | }
121 | }
122 |
```
--------------------------------------------------------------------------------
/src/features/repositories/list-commits/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | GitChange,
4 | GitVersionType,
5 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
6 | import { createTwoFilesPatch } from 'diff';
7 | import { AzureDevOpsError } from '../../../shared/errors';
8 | import {
9 | CommitWithContent,
10 | ListCommitsOptions,
11 | ListCommitsResponse,
12 | } from '../types';
13 |
14 | async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
15 | const chunks: Buffer[] = [];
16 | return await new Promise<string>((resolve, reject) => {
17 | stream.on('data', (c) => chunks.push(Buffer.from(c)));
18 | stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
19 | stream.on('error', (err) => reject(err));
20 | });
21 | }
22 |
23 | /**
24 | * List commits on a branch including their file level diffs
25 | */
26 | export async function listCommits(
27 | connection: WebApi,
28 | options: ListCommitsOptions,
29 | ): Promise<ListCommitsResponse> {
30 | try {
31 | const gitApi = await connection.getGitApi();
32 | const commits = await gitApi.getCommits(
33 | options.repositoryId,
34 | {
35 | itemVersion: {
36 | version: options.branchName,
37 | versionType: GitVersionType.Branch,
38 | },
39 | $top: options.top ?? 10,
40 | $skip: options.skip,
41 | },
42 | options.projectId,
43 | );
44 |
45 | if (!commits || commits.length === 0) {
46 | return { commits: [] };
47 | }
48 |
49 | const getBlobText = async (objId?: string): Promise<string> => {
50 | if (!objId) {
51 | return '';
52 | }
53 | const stream = await gitApi.getBlobContent(
54 | options.repositoryId,
55 | objId,
56 | options.projectId,
57 | );
58 | return stream ? await streamToString(stream) : '';
59 | };
60 |
61 | const commitsWithContent: CommitWithContent[] = [];
62 |
63 | for (const commit of commits) {
64 | const commitId = commit.commitId;
65 | if (!commitId) {
66 | continue;
67 | }
68 |
69 | const commitChanges = await gitApi.getChanges(
70 | commitId,
71 | options.repositoryId,
72 | options.projectId,
73 | );
74 | const changeEntries = commitChanges?.changes ?? [];
75 |
76 | const files = await Promise.all(
77 | changeEntries.map(async (entry: GitChange) => {
78 | const path = entry.item?.path || entry.originalPath || '';
79 | const [oldContent, newContent] = await Promise.all([
80 | getBlobText(entry.item?.originalObjectId),
81 | getBlobText(entry.item?.objectId),
82 | ]);
83 | const patch = createTwoFilesPatch(
84 | entry.originalPath || path,
85 | path,
86 | oldContent,
87 | newContent,
88 | );
89 | return { path, patch };
90 | }),
91 | );
92 |
93 | commitsWithContent.push({
94 | commitId,
95 | comment: commit.comment,
96 | author: commit.author,
97 | committer: commit.committer,
98 | url: commit.url,
99 | parents: commit.parents,
100 | files,
101 | });
102 | }
103 |
104 | return { commits: commitsWithContent };
105 | } catch (error) {
106 | if (error instanceof AzureDevOpsError) {
107 | throw error;
108 | }
109 | throw new Error(
110 | `Failed to list commits: ${error instanceof Error ? error.message : String(error)}`,
111 | );
112 | }
113 | }
114 |
```
--------------------------------------------------------------------------------
/src/features/pipelines/get-pipeline-log/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { getPipelineLog } from './feature';
3 | import {
4 | AzureDevOpsAuthenticationError,
5 | AzureDevOpsError,
6 | AzureDevOpsResourceNotFoundError,
7 | } from '../../../shared/errors';
8 |
9 | describe('getPipelineLog unit', () => {
10 | let mockConnection: WebApi;
11 | let mockBuildApi: any;
12 | let mockRestGet: jest.Mock;
13 |
14 | beforeEach(() => {
15 | jest.resetAllMocks();
16 |
17 | mockRestGet = jest.fn();
18 | mockBuildApi = {
19 | rest: { get: mockRestGet },
20 | createRequestOptions: jest
21 | .fn()
22 | .mockReturnValue({ acceptHeader: 'application/json' }),
23 | getBuildLogLines: jest.fn(),
24 | };
25 |
26 | mockConnection = {
27 | serverUrl: 'https://dev.azure.com/testorg',
28 | getBuildApi: jest.fn().mockResolvedValue(mockBuildApi),
29 | } as unknown as WebApi;
30 | });
31 |
32 | it('retrieves the pipeline log with query parameters', async () => {
33 | mockRestGet.mockResolvedValue({
34 | statusCode: 200,
35 | result: 'log content',
36 | headers: {},
37 | });
38 |
39 | const result = await getPipelineLog(mockConnection, {
40 | projectId: 'test-project',
41 | runId: 101,
42 | logId: 7,
43 | format: 'json',
44 | startLine: 10,
45 | endLine: 20,
46 | });
47 |
48 | expect(result).toEqual('log content');
49 | expect(mockBuildApi.createRequestOptions).toHaveBeenCalledWith(
50 | 'application/json',
51 | '7.1',
52 | );
53 | const [requestUrl] = mockRestGet.mock.calls[0];
54 | const url = new URL(requestUrl);
55 | expect(url.pathname).toContain('/build/builds/101/logs/7');
56 | expect(url.searchParams.get('format')).toBe('json');
57 | expect(url.searchParams.get('startLine')).toBe('10');
58 | expect(url.searchParams.get('endLine')).toBe('20');
59 | });
60 |
61 | it('defaults to plain text when format not provided', async () => {
62 | mockBuildApi.getBuildLogLines.mockResolvedValue(['line1', 'line2']);
63 |
64 | await getPipelineLog(mockConnection, {
65 | projectId: 'test-project',
66 | runId: 101,
67 | logId: 7,
68 | });
69 |
70 | expect(mockBuildApi.getBuildLogLines).toHaveBeenCalledWith(
71 | 'test-project',
72 | 101,
73 | 7,
74 | undefined,
75 | undefined,
76 | );
77 | });
78 |
79 | it('throws resource not found when API returns 404', async () => {
80 | mockBuildApi.getBuildLogLines.mockResolvedValue(undefined);
81 |
82 | await expect(
83 | getPipelineLog(mockConnection, {
84 | projectId: 'test-project',
85 | runId: 101,
86 | logId: 7,
87 | }),
88 | ).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
89 | });
90 |
91 | it('maps authentication errors', async () => {
92 | mockBuildApi.getBuildLogLines.mockRejectedValue(
93 | new Error('401 Unauthorized'),
94 | );
95 |
96 | await expect(
97 | getPipelineLog(mockConnection, {
98 | projectId: 'test-project',
99 | runId: 101,
100 | logId: 7,
101 | }),
102 | ).rejects.toBeInstanceOf(AzureDevOpsAuthenticationError);
103 | });
104 |
105 | it('wraps unexpected errors', async () => {
106 | mockBuildApi.getBuildLogLines.mockRejectedValue(new Error('Boom'));
107 |
108 | await expect(
109 | getPipelineLog(mockConnection, {
110 | projectId: 'test-project',
111 | runId: 101,
112 | logId: 7,
113 | }),
114 | ).rejects.toBeInstanceOf(AzureDevOpsError);
115 | });
116 | });
117 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/create-pull-request/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { createPullRequest } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 | import { GitRefUpdate } from 'azure-devops-node-api/interfaces/GitInterfaces';
8 |
9 | describe('createPullRequest integration', () => {
10 | let connection: WebApi | null = null;
11 |
12 | beforeAll(async () => {
13 | // Get a real connection using environment variables
14 | connection = await getTestConnection();
15 | });
16 |
17 | test('should create a new pull request in Azure DevOps', async () => {
18 | // Skip if no connection is available
19 | if (shouldSkipIntegrationTest()) {
20 | return;
21 | }
22 |
23 | // This connection must be available if we didn't skip
24 | if (!connection) {
25 | throw new Error(
26 | 'Connection should be available when test is not skipped',
27 | );
28 | }
29 |
30 | // Create a unique title using timestamp to avoid conflicts
31 | const uniqueTitle = `Test Pull Request ${new Date().toISOString()}`;
32 |
33 | // For a true integration test, use a real project and repository
34 | const projectName =
35 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
36 | const repositoryId =
37 | process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || 'DefaultRepo';
38 |
39 | // Create a unique branch name
40 | const uniqueBranchName = `test-branch-${new Date().getTime()}`;
41 |
42 | // Get the Git API
43 | const gitApi = await connection.getGitApi();
44 |
45 | // Get the main branch's object ID
46 | const refs = await gitApi.getRefs(repositoryId, projectName, 'heads/main');
47 | if (!refs || refs.length === 0) {
48 | throw new Error('Could not find main branch');
49 | }
50 |
51 | const mainBranchObjectId = refs[0].objectId;
52 |
53 | // Create a new branch from main
54 | const refUpdate: GitRefUpdate = {
55 | name: `refs/heads/${uniqueBranchName}`,
56 | oldObjectId: '0000000000000000000000000000000000000000', // Required for new branch creation
57 | newObjectId: mainBranchObjectId,
58 | };
59 |
60 | const updateResult = await gitApi.updateRefs(
61 | [refUpdate],
62 | repositoryId,
63 | projectName,
64 | );
65 |
66 | if (
67 | !updateResult ||
68 | updateResult.length === 0 ||
69 | !updateResult[0].success
70 | ) {
71 | throw new Error('Failed to create new branch');
72 | }
73 |
74 | // Create a pull request with the new branch
75 | const result = await createPullRequest(
76 | connection,
77 | projectName,
78 | repositoryId,
79 | {
80 | title: uniqueTitle,
81 | description:
82 | 'This is a test pull request created by an integration test',
83 | sourceRefName: `refs/heads/${uniqueBranchName}`,
84 | targetRefName: 'refs/heads/main',
85 | isDraft: true,
86 | },
87 | );
88 |
89 | // Assert on the actual response
90 | expect(result).toBeDefined();
91 | expect(result.pullRequestId).toBeDefined();
92 | expect(result.title).toBe(uniqueTitle);
93 | expect(result.description).toBe(
94 | 'This is a test pull request created by an integration test',
95 | );
96 | expect(result.sourceRefName).toBe(`refs/heads/${uniqueBranchName}`);
97 | expect(result.targetRefName).toBe('refs/heads/main');
98 | expect(result.isDraft).toBe(true);
99 | });
100 | });
101 |
```
--------------------------------------------------------------------------------
/src/features/work-items/get-work-item/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | WorkItemExpand,
4 | WorkItemTypeFieldsExpandLevel,
5 | WorkItemTypeFieldWithReferences,
6 | } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
7 | import {
8 | AzureDevOpsResourceNotFoundError,
9 | AzureDevOpsError,
10 | } from '../../../shared/errors';
11 | import { WorkItem } from '../types';
12 |
13 | const workItemTypeFieldsCache: Record<
14 | string,
15 | Record<string, WorkItemTypeFieldWithReferences[]>
16 | > = {};
17 |
18 | /**
19 | * Maps string-based expansion options to the WorkItemExpand enum
20 | */
21 | const expandMap: Record<string, WorkItemExpand> = {
22 | none: WorkItemExpand.None,
23 | relations: WorkItemExpand.Relations,
24 | fields: WorkItemExpand.Fields,
25 | links: WorkItemExpand.Links,
26 | all: WorkItemExpand.All,
27 | };
28 |
29 | /**
30 | * Get a work item by ID
31 | *
32 | * @param connection The Azure DevOps WebApi connection
33 | * @param workItemId The ID of the work item
34 | * @param expand Optional expansion options (defaults to 'all')
35 | * @returns The work item details
36 | * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found
37 | */
38 | export async function getWorkItem(
39 | connection: WebApi,
40 | workItemId: number,
41 | expand: string = 'all',
42 | ): Promise<WorkItem> {
43 | try {
44 | const witApi = await connection.getWorkItemTrackingApi();
45 |
46 | const workItem = await witApi.getWorkItem(
47 | workItemId,
48 | undefined,
49 | undefined,
50 | expandMap[expand.toLowerCase()],
51 | );
52 |
53 | if (!workItem) {
54 | throw new AzureDevOpsResourceNotFoundError(
55 | `Work item '${workItemId}' not found`,
56 | );
57 | }
58 |
59 | // Extract project and work item type to get all possible fields
60 | const projectName = workItem.fields?.['System.TeamProject'];
61 | const workItemType = workItem.fields?.['System.WorkItemType'];
62 |
63 | if (!projectName || !workItemType) {
64 | // If we can't determine the project or type, return the original work item
65 | return workItem;
66 | }
67 |
68 | // Get all possible fields for this work item type
69 | const allFields =
70 | workItemTypeFieldsCache[projectName.toString()]?.[
71 | workItemType.toString()
72 | ] ??
73 | (await witApi.getWorkItemTypeFieldsWithReferences(
74 | projectName.toString(),
75 | workItemType.toString(),
76 | WorkItemTypeFieldsExpandLevel.All,
77 | ));
78 |
79 | workItemTypeFieldsCache[projectName.toString()] = {
80 | ...workItemTypeFieldsCache[projectName.toString()],
81 | [workItemType.toString()]: allFields,
82 | };
83 |
84 | // Create a new work item object with all fields
85 | const enhancedWorkItem = { ...workItem };
86 |
87 | // Initialize fields object if it doesn't exist
88 | if (!enhancedWorkItem.fields) {
89 | enhancedWorkItem.fields = {};
90 | }
91 |
92 | // Set null for all potential fields that don't have values
93 | for (const field of allFields) {
94 | if (
95 | field.referenceName &&
96 | !(field.referenceName in enhancedWorkItem.fields)
97 | ) {
98 | enhancedWorkItem.fields[field.referenceName] = field.defaultValue;
99 | }
100 | }
101 |
102 | return enhancedWorkItem;
103 | } catch (error) {
104 | if (error instanceof AzureDevOpsError) {
105 | throw error;
106 | }
107 | throw new Error(
108 | `Failed to get work item: ${error instanceof Error ? error.message : String(error)}`,
109 | );
110 | }
111 | }
112 |
```
--------------------------------------------------------------------------------
/src/features/work-items/update-work-item/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { WorkItemExpand } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
3 | import {
4 | AzureDevOpsResourceNotFoundError,
5 | AzureDevOpsError,
6 | } from '../../../shared/errors';
7 | import { UpdateWorkItemOptions, WorkItem } from '../types';
8 |
9 | /**
10 | * Update a work item
11 | *
12 | * @param connection The Azure DevOps WebApi connection
13 | * @param workItemId The ID of the work item to update
14 | * @param options Options for updating the work item
15 | * @returns The updated work item
16 | * @throws {AzureDevOpsResourceNotFoundError} If the work item is not found
17 | */
18 | export async function updateWorkItem(
19 | connection: WebApi,
20 | workItemId: number,
21 | options: UpdateWorkItemOptions,
22 | ): Promise<WorkItem> {
23 | try {
24 | const witApi = await connection.getWorkItemTrackingApi();
25 |
26 | // Create the JSON patch document
27 | const document = [];
28 |
29 | // Add optional fields if provided
30 | if (options.title) {
31 | document.push({
32 | op: 'add',
33 | path: '/fields/System.Title',
34 | value: options.title,
35 | });
36 | }
37 |
38 | if (options.description) {
39 | document.push({
40 | op: 'add',
41 | path: '/fields/System.Description',
42 | value: options.description,
43 | });
44 | }
45 |
46 | if (options.assignedTo) {
47 | document.push({
48 | op: 'add',
49 | path: '/fields/System.AssignedTo',
50 | value: options.assignedTo,
51 | });
52 | }
53 |
54 | if (options.areaPath) {
55 | document.push({
56 | op: 'add',
57 | path: '/fields/System.AreaPath',
58 | value: options.areaPath,
59 | });
60 | }
61 |
62 | if (options.iterationPath) {
63 | document.push({
64 | op: 'add',
65 | path: '/fields/System.IterationPath',
66 | value: options.iterationPath,
67 | });
68 | }
69 |
70 | if (options.priority) {
71 | document.push({
72 | op: 'add',
73 | path: '/fields/Microsoft.VSTS.Common.Priority',
74 | value: options.priority,
75 | });
76 | }
77 |
78 | if (options.state) {
79 | document.push({
80 | op: 'add',
81 | path: '/fields/System.State',
82 | value: options.state,
83 | });
84 | }
85 |
86 | // Add any additional fields
87 | if (options.additionalFields) {
88 | for (const [key, value] of Object.entries(options.additionalFields)) {
89 | document.push({
90 | op: 'add',
91 | path: `/fields/${key}`,
92 | value: value,
93 | });
94 | }
95 | }
96 |
97 | // If no fields to update, throw an error
98 | if (document.length === 0) {
99 | throw new Error('At least one field must be provided for update');
100 | }
101 |
102 | // Update the work item
103 | const updatedWorkItem = await witApi.updateWorkItem(
104 | {}, // customHeaders
105 | document,
106 | workItemId,
107 | undefined, // project
108 | false, // validateOnly
109 | false, // bypassRules
110 | false, // suppressNotifications
111 | WorkItemExpand.All, // expand
112 | );
113 |
114 | if (!updatedWorkItem) {
115 | throw new AzureDevOpsResourceNotFoundError(
116 | `Work item '${workItemId}' not found`,
117 | );
118 | }
119 |
120 | return updatedWorkItem;
121 | } catch (error) {
122 | if (error instanceof AzureDevOpsError) {
123 | throw error;
124 | }
125 | throw new Error(
126 | `Failed to update work item: ${error instanceof Error ? error.message : String(error)}`,
127 | );
128 | }
129 | }
130 |
```
--------------------------------------------------------------------------------
/src/features/wikis/get-wiki-page/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { getWikiPage, GetWikiPageOptions } from './feature';
2 | import {
3 | AzureDevOpsResourceNotFoundError,
4 | AzureDevOpsPermissionError,
5 | AzureDevOpsError,
6 | } from '../../../shared/errors';
7 | import * as azureDevOpsClient from '../../../clients/azure-devops';
8 |
9 | // Mock Azure DevOps client
10 | jest.mock('../../../clients/azure-devops');
11 | const mockGetPage = jest.fn();
12 |
13 | (azureDevOpsClient.getWikiClient as jest.Mock).mockImplementation(() => {
14 | return Promise.resolve({
15 | getPage: mockGetPage,
16 | });
17 | });
18 |
19 | describe('getWikiPage unit', () => {
20 | const mockWikiPageContent = 'Wiki page content text';
21 |
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | mockGetPage.mockResolvedValue({ content: mockWikiPageContent });
25 | });
26 |
27 | it('should return wiki page content as text', async () => {
28 | // Arrange
29 | const options: GetWikiPageOptions = {
30 | organizationId: 'testOrg',
31 | projectId: 'testProject',
32 | wikiId: 'testWiki',
33 | pagePath: '/Home',
34 | };
35 |
36 | // Act
37 | const result = await getWikiPage(options);
38 |
39 | // Assert
40 | expect(result).toBe(mockWikiPageContent);
41 | expect(azureDevOpsClient.getWikiClient).toHaveBeenCalledWith({
42 | organizationId: 'testOrg',
43 | });
44 | expect(mockGetPage).toHaveBeenCalledWith(
45 | 'testProject',
46 | 'testWiki',
47 | '/Home',
48 | );
49 | });
50 |
51 | it('should properly handle wiki page path', async () => {
52 | // Arrange
53 | const options: GetWikiPageOptions = {
54 | organizationId: 'testOrg',
55 | projectId: 'testProject',
56 | wikiId: 'testWiki',
57 | pagePath: '/Path with spaces/And special chars $&+,/:;=?@',
58 | };
59 |
60 | // Act
61 | await getWikiPage(options);
62 |
63 | // Assert
64 | expect(mockGetPage).toHaveBeenCalledWith(
65 | 'testProject',
66 | 'testWiki',
67 | '/Path with spaces/And special chars $&+,/:;=?@',
68 | );
69 | });
70 |
71 | it('should throw ResourceNotFoundError when wiki page is not found', async () => {
72 | // Arrange
73 | mockGetPage.mockRejectedValue(
74 | new AzureDevOpsResourceNotFoundError('Page not found'),
75 | );
76 |
77 | // Act & Assert
78 | const options: GetWikiPageOptions = {
79 | organizationId: 'testOrg',
80 | projectId: 'testProject',
81 | wikiId: 'testWiki',
82 | pagePath: '/NonExistentPage',
83 | };
84 |
85 | await expect(getWikiPage(options)).rejects.toThrow(
86 | AzureDevOpsResourceNotFoundError,
87 | );
88 | });
89 |
90 | it('should throw PermissionError when user lacks permissions', async () => {
91 | // Arrange
92 | mockGetPage.mockRejectedValue(
93 | new AzureDevOpsPermissionError('Permission denied'),
94 | );
95 |
96 | // Act & Assert
97 | const options: GetWikiPageOptions = {
98 | organizationId: 'testOrg',
99 | projectId: 'testProject',
100 | wikiId: 'testWiki',
101 | pagePath: '/RestrictedPage',
102 | };
103 |
104 | await expect(getWikiPage(options)).rejects.toThrow(
105 | AzureDevOpsPermissionError,
106 | );
107 | });
108 |
109 | it('should throw generic error for other failures', async () => {
110 | // Arrange
111 | mockGetPage.mockRejectedValue(new Error('Network error'));
112 |
113 | // Act & Assert
114 | const options: GetWikiPageOptions = {
115 | organizationId: 'testOrg',
116 | projectId: 'testProject',
117 | wikiId: 'testWiki',
118 | pagePath: '/AnyPage',
119 | };
120 |
121 | await expect(getWikiPage(options)).rejects.toThrow(AzureDevOpsError);
122 | });
123 | });
124 |
```
--------------------------------------------------------------------------------
/src/features/work-items/manage-work-item-link/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | AzureDevOpsResourceNotFoundError,
4 | AzureDevOpsError,
5 | } from '../../../shared/errors';
6 | import { WorkItem } from '../types';
7 |
8 | /**
9 | * Options for managing work item link
10 | */
11 | interface ManageWorkItemLinkOptions {
12 | sourceWorkItemId: number;
13 | targetWorkItemId: number;
14 | operation: 'add' | 'remove' | 'update';
15 | relationType: string;
16 | newRelationType?: string;
17 | comment?: string;
18 | }
19 |
20 | /**
21 | * Manage (add, remove, or update) a link between two work items
22 | *
23 | * @param connection The Azure DevOps WebApi connection
24 | * @param projectId The ID or name of the project
25 | * @param options Options for managing the work item link
26 | * @returns The updated source work item
27 | * @throws {AzureDevOpsResourceNotFoundError} If either work item is not found
28 | */
29 | export async function manageWorkItemLink(
30 | connection: WebApi,
31 | projectId: string,
32 | options: ManageWorkItemLinkOptions,
33 | ): Promise<WorkItem> {
34 | try {
35 | const {
36 | sourceWorkItemId,
37 | targetWorkItemId,
38 | operation,
39 | relationType,
40 | newRelationType,
41 | comment,
42 | } = options;
43 |
44 | // Input validation
45 | if (!sourceWorkItemId) {
46 | throw new Error('Source work item ID is required');
47 | }
48 |
49 | if (!targetWorkItemId) {
50 | throw new Error('Target work item ID is required');
51 | }
52 |
53 | if (!relationType) {
54 | throw new Error('Relation type is required');
55 | }
56 |
57 | if (operation === 'update' && !newRelationType) {
58 | throw new Error('New relation type is required for update operation');
59 | }
60 |
61 | const witApi = await connection.getWorkItemTrackingApi();
62 |
63 | // Create the JSON patch document
64 | const document = [];
65 |
66 | // Construct the relationship URL
67 | const relationshipUrl = `${connection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`;
68 |
69 | if (operation === 'add' || operation === 'update') {
70 | // For 'update', we'll first remove the old link, then add the new one
71 | if (operation === 'update') {
72 | document.push({
73 | op: 'remove',
74 | path: `/relations/+[rel=${relationType};url=${relationshipUrl}]`,
75 | });
76 | }
77 |
78 | // Add the new relationship
79 | document.push({
80 | op: 'add',
81 | path: '/relations/-',
82 | value: {
83 | rel: operation === 'update' ? newRelationType : relationType,
84 | url: relationshipUrl,
85 | ...(comment ? { attributes: { comment } } : {}),
86 | },
87 | });
88 | } else if (operation === 'remove') {
89 | // Remove the relationship
90 | document.push({
91 | op: 'remove',
92 | path: `/relations/+[rel=${relationType};url=${relationshipUrl}]`,
93 | });
94 | }
95 |
96 | // Update the work item with the new relationship
97 | const updatedWorkItem = await witApi.updateWorkItem(
98 | {}, // customHeaders
99 | document,
100 | sourceWorkItemId,
101 | projectId,
102 | );
103 |
104 | if (!updatedWorkItem) {
105 | throw new AzureDevOpsResourceNotFoundError(
106 | `Work item '${sourceWorkItemId}' not found`,
107 | );
108 | }
109 |
110 | return updatedWorkItem;
111 | } catch (error) {
112 | if (error instanceof AzureDevOpsError) {
113 | throw error;
114 | }
115 | throw new Error(
116 | `Failed to manage work item link: ${error instanceof Error ? error.message : String(error)}`,
117 | );
118 | }
119 | }
120 |
```
--------------------------------------------------------------------------------
/src/features/organizations/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
3 | import { isOrganizationsRequest, handleOrganizationsRequest } from './';
4 | import { AuthenticationMethod } from '../../shared/auth';
5 | import * as listOrganizationsFeature from './list-organizations';
6 |
7 | // Mock the listOrganizations function
8 | jest.mock('./list-organizations');
9 |
10 | describe('Organizations Request Handlers', () => {
11 | describe('isOrganizationsRequest', () => {
12 | it('should return true for organizations requests', () => {
13 | const request = {
14 | params: { name: 'list_organizations', arguments: {} },
15 | } as CallToolRequest;
16 |
17 | expect(isOrganizationsRequest(request)).toBe(true);
18 | });
19 |
20 | it('should return false for non-organizations requests', () => {
21 | const request = {
22 | params: { name: 'get_project', arguments: {} },
23 | } as CallToolRequest;
24 |
25 | expect(isOrganizationsRequest(request)).toBe(false);
26 | });
27 | });
28 |
29 | describe('handleOrganizationsRequest', () => {
30 | const mockConnection = {
31 | serverUrl: 'https://dev.azure.com/mock-org',
32 | } as unknown as WebApi;
33 |
34 | beforeEach(() => {
35 | jest.resetAllMocks();
36 | // Mock environment variables
37 | process.env.AZURE_DEVOPS_AUTH_METHOD = 'pat';
38 | process.env.AZURE_DEVOPS_PAT = 'mock-pat';
39 | });
40 |
41 | it('should handle list_organizations request', async () => {
42 | const mockOrgs = [
43 | { id: '1', name: 'org1', url: 'https://dev.azure.com/org1' },
44 | { id: '2', name: 'org2', url: 'https://dev.azure.com/org2' },
45 | ];
46 |
47 | (
48 | listOrganizationsFeature.listOrganizations as jest.Mock
49 | ).mockResolvedValue(mockOrgs);
50 |
51 | const request = {
52 | params: { name: 'list_organizations', arguments: {} },
53 | } as CallToolRequest;
54 |
55 | const response = await handleOrganizationsRequest(
56 | mockConnection,
57 | request,
58 | );
59 |
60 | expect(response).toEqual({
61 | content: [{ type: 'text', text: JSON.stringify(mockOrgs, null, 2) }],
62 | });
63 |
64 | expect(listOrganizationsFeature.listOrganizations).toHaveBeenCalledWith({
65 | authMethod: AuthenticationMethod.PersonalAccessToken,
66 | personalAccessToken: 'mock-pat',
67 | organizationUrl: 'https://dev.azure.com/mock-org',
68 | });
69 | });
70 |
71 | it('should throw error for unknown tool', async () => {
72 | const request = {
73 | params: { name: 'unknown_tool', arguments: {} },
74 | } as CallToolRequest;
75 |
76 | await expect(
77 | handleOrganizationsRequest(mockConnection, request),
78 | ).rejects.toThrow('Unknown organizations tool: unknown_tool');
79 | });
80 |
81 | it('should propagate errors from listOrganizations', async () => {
82 | const mockError = new Error('Test error');
83 | (
84 | listOrganizationsFeature.listOrganizations as jest.Mock
85 | ).mockRejectedValue(mockError);
86 |
87 | const request = {
88 | params: { name: 'list_organizations', arguments: {} },
89 | } as CallToolRequest;
90 |
91 | await expect(
92 | handleOrganizationsRequest(mockConnection, request),
93 | ).rejects.toThrow(mockError);
94 | });
95 |
96 | afterEach(() => {
97 | // Clean up environment variables
98 | delete process.env.AZURE_DEVOPS_AUTH_METHOD;
99 | delete process.env.AZURE_DEVOPS_PAT;
100 | });
101 | });
102 | });
103 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | #!/usr/bin/env node
2 | /**
3 | * Entry point for the Azure DevOps MCP Server
4 | */
5 |
6 | import { createAzureDevOpsServer } from './server';
7 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8 | import dotenv from 'dotenv';
9 | import { AzureDevOpsConfig } from './shared/types';
10 | import { AuthenticationMethod } from './shared/auth/auth-factory';
11 |
12 | /**
13 | * Normalize auth method string to a valid AuthenticationMethod enum value
14 | * in a case-insensitive manner
15 | *
16 | * @param authMethodStr The auth method string from environment variable
17 | * @returns A valid AuthenticationMethod value
18 | */
19 | export function normalizeAuthMethod(
20 | authMethodStr?: string,
21 | ): AuthenticationMethod {
22 | if (!authMethodStr) {
23 | return AuthenticationMethod.AzureIdentity; // Default
24 | }
25 |
26 | // Convert to lowercase for case-insensitive comparison
27 | const normalizedMethod = authMethodStr.toLowerCase();
28 |
29 | // Check against known enum values (as lowercase strings)
30 | if (
31 | normalizedMethod === AuthenticationMethod.PersonalAccessToken.toLowerCase()
32 | ) {
33 | return AuthenticationMethod.PersonalAccessToken;
34 | } else if (
35 | normalizedMethod === AuthenticationMethod.AzureIdentity.toLowerCase()
36 | ) {
37 | return AuthenticationMethod.AzureIdentity;
38 | } else if (normalizedMethod === AuthenticationMethod.AzureCli.toLowerCase()) {
39 | return AuthenticationMethod.AzureCli;
40 | }
41 |
42 | // If not recognized, log a warning and use the default
43 | process.stderr.write(
44 | `WARNING: Unrecognized auth method '${authMethodStr}'. Using default (${AuthenticationMethod.AzureIdentity}).\n`,
45 | );
46 | return AuthenticationMethod.AzureIdentity;
47 | }
48 |
49 | // Load environment variables
50 | dotenv.config();
51 |
52 | function getConfig(): AzureDevOpsConfig {
53 | // Debug log the environment variables to help diagnose issues
54 | process.stderr.write(`DEBUG - Environment variables in getConfig():
55 | AZURE_DEVOPS_ORG_URL: ${process.env.AZURE_DEVOPS_ORG_URL || 'NOT SET'}
56 | AZURE_DEVOPS_AUTH_METHOD: ${process.env.AZURE_DEVOPS_AUTH_METHOD || 'NOT SET'}
57 | AZURE_DEVOPS_PAT: ${process.env.AZURE_DEVOPS_PAT ? 'SET (hidden)' : 'NOT SET'}
58 | AZURE_DEVOPS_DEFAULT_PROJECT: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'NOT SET'}
59 | AZURE_DEVOPS_API_VERSION: ${process.env.AZURE_DEVOPS_API_VERSION || 'NOT SET'}
60 | NODE_ENV: ${process.env.NODE_ENV || 'NOT SET'}
61 | \n`);
62 |
63 | return {
64 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '',
65 | authMethod: normalizeAuthMethod(process.env.AZURE_DEVOPS_AUTH_METHOD),
66 | personalAccessToken: process.env.AZURE_DEVOPS_PAT,
67 | defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT,
68 | apiVersion: process.env.AZURE_DEVOPS_API_VERSION,
69 | };
70 | }
71 |
72 | async function main() {
73 | try {
74 | // Create the server with configuration
75 | const server = createAzureDevOpsServer(getConfig());
76 |
77 | // Connect to stdio transport
78 | const transport = new StdioServerTransport();
79 | await server.connect(transport);
80 |
81 | process.stderr.write('Azure DevOps MCP Server running on stdio\n');
82 | } catch (error) {
83 | process.stderr.write(`Error starting server: ${error}\n`);
84 | process.exit(1);
85 | }
86 | }
87 |
88 | // Start the server when this script is run directly
89 | if (require.main === module) {
90 | main().catch((error) => {
91 | process.stderr.write(`Fatal error in main(): ${error}\n`);
92 | process.exit(1);
93 | });
94 | }
95 |
96 | // Export the server and related components
97 | export * from './server';
98 |
```
--------------------------------------------------------------------------------
/src/features/wikis/get-wikis/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { WikiV2 } from 'azure-devops-node-api/interfaces/WikiInterfaces';
3 | import {
4 | AzureDevOpsResourceNotFoundError,
5 | AzureDevOpsError,
6 | } from '../../../shared/errors';
7 | import { getWikis } from './feature';
8 |
9 | // Mock the Azure DevOps WebApi
10 | jest.mock('azure-devops-node-api');
11 |
12 | describe('getWikis unit', () => {
13 | // Mock WikiApi client
14 | const mockWikiApi = {
15 | getAllWikis: jest.fn(),
16 | };
17 |
18 | // Mock WebApi connection
19 | const mockConnection = {
20 | getWikiApi: jest.fn().mockResolvedValue(mockWikiApi),
21 | } as unknown as WebApi;
22 |
23 | beforeEach(() => {
24 | // Clear mock calls between tests
25 | jest.clearAllMocks();
26 | });
27 |
28 | test('should return wikis for a project', async () => {
29 | // Mock data
30 | const mockWikis: WikiV2[] = [
31 | {
32 | id: 'wiki1',
33 | name: 'Project Wiki',
34 | mappedPath: '/',
35 | remoteUrl: 'https://example.com/wiki1',
36 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1',
37 | },
38 | {
39 | id: 'wiki2',
40 | name: 'Code Wiki',
41 | mappedPath: '/docs',
42 | remoteUrl: 'https://example.com/wiki2',
43 | url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki2',
44 | },
45 | ];
46 |
47 | // Setup mock responses
48 | mockWikiApi.getAllWikis.mockResolvedValue(mockWikis);
49 |
50 | // Call the function
51 | const result = await getWikis(mockConnection, {
52 | projectId: 'testProject',
53 | });
54 |
55 | // Assertions
56 | expect(mockConnection.getWikiApi).toHaveBeenCalledTimes(1);
57 | expect(mockWikiApi.getAllWikis).toHaveBeenCalledWith('testProject');
58 | expect(result).toEqual(mockWikis);
59 | expect(result.length).toBe(2);
60 | });
61 |
62 | test('should return empty array when no wikis are found', async () => {
63 | // Setup mock responses
64 | mockWikiApi.getAllWikis.mockResolvedValue([]);
65 |
66 | // Call the function
67 | const result = await getWikis(mockConnection, {
68 | projectId: 'projectWithNoWikis',
69 | });
70 |
71 | // Assertions
72 | expect(mockConnection.getWikiApi).toHaveBeenCalledTimes(1);
73 | expect(mockWikiApi.getAllWikis).toHaveBeenCalledWith('projectWithNoWikis');
74 | expect(result).toEqual([]);
75 | });
76 |
77 | test('should handle API errors gracefully', async () => {
78 | // Setup mock to throw an error
79 | const mockError = new Error('API error occurred');
80 | mockWikiApi.getAllWikis.mockRejectedValue(mockError);
81 |
82 | // Call the function and expect it to throw
83 | await expect(
84 | getWikis(mockConnection, { projectId: 'testProject' }),
85 | ).rejects.toThrow(AzureDevOpsError);
86 |
87 | // Assertions
88 | expect(mockConnection.getWikiApi).toHaveBeenCalledTimes(1);
89 | expect(mockWikiApi.getAllWikis).toHaveBeenCalledWith('testProject');
90 | });
91 |
92 | test('should throw ResourceNotFoundError for non-existent project', async () => {
93 | // Setup mock to throw an error with specific resource not found message
94 | const mockError = new Error('The resource cannot be found');
95 | mockWikiApi.getAllWikis.mockRejectedValue(mockError);
96 |
97 | // Call the function and expect it to throw a specific error type
98 | await expect(
99 | getWikis(mockConnection, { projectId: 'nonExistentProject' }),
100 | ).rejects.toThrow(AzureDevOpsResourceNotFoundError);
101 |
102 | // Assertions
103 | expect(mockConnection.getWikiApi).toHaveBeenCalledTimes(1);
104 | expect(mockWikiApi.getAllWikis).toHaveBeenCalledWith('nonExistentProject');
105 | });
106 | });
107 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/list-pull-requests/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { AzureDevOpsError } from '../../../shared/errors';
3 | import { ListPullRequestsOptions, PullRequest } from '../types';
4 | import {
5 | GitPullRequestSearchCriteria,
6 | PullRequestStatus,
7 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
8 |
9 | /**
10 | * List pull requests for a repository
11 | *
12 | * @param connection The Azure DevOps WebApi connection
13 | * @param projectId The ID or name of the project
14 | * @param repositoryId The ID or name of the repository
15 | * @param options Options for filtering pull requests
16 | * @returns Object containing pull requests array and pagination metadata
17 | */
18 | export async function listPullRequests(
19 | connection: WebApi,
20 | projectId: string,
21 | repositoryId: string,
22 | options: ListPullRequestsOptions,
23 | ): Promise<{
24 | count: number;
25 | value: PullRequest[];
26 | hasMoreResults: boolean;
27 | warning?: string;
28 | }> {
29 | try {
30 | const gitApi = await connection.getGitApi();
31 |
32 | if (options.pullRequestId !== undefined) {
33 | const pullRequest = await gitApi.getPullRequest(
34 | repositoryId,
35 | options.pullRequestId,
36 | projectId,
37 | );
38 |
39 | const value = pullRequest ? [pullRequest] : [];
40 | return {
41 | count: value.length,
42 | value,
43 | hasMoreResults: false,
44 | warning: undefined,
45 | };
46 | }
47 |
48 | // Create search criteria
49 | const searchCriteria: GitPullRequestSearchCriteria = {};
50 |
51 | // Add filters if provided
52 | if (options.status) {
53 | // Map our status enum to Azure DevOps PullRequestStatus
54 | switch (options.status) {
55 | case 'active':
56 | searchCriteria.status = PullRequestStatus.Active;
57 | break;
58 | case 'abandoned':
59 | searchCriteria.status = PullRequestStatus.Abandoned;
60 | break;
61 | case 'completed':
62 | searchCriteria.status = PullRequestStatus.Completed;
63 | break;
64 | case 'all':
65 | // Don't set status to get all
66 | break;
67 | }
68 | }
69 |
70 | if (options.creatorId) {
71 | searchCriteria.creatorId = options.creatorId;
72 | }
73 |
74 | if (options.reviewerId) {
75 | searchCriteria.reviewerId = options.reviewerId;
76 | }
77 |
78 | if (options.sourceRefName) {
79 | searchCriteria.sourceRefName = options.sourceRefName;
80 | }
81 |
82 | if (options.targetRefName) {
83 | searchCriteria.targetRefName = options.targetRefName;
84 | }
85 |
86 | // Set default values for pagination
87 | const top = options.top ?? 10;
88 | const skip = options.skip ?? 0;
89 |
90 | // List pull requests with search criteria
91 | const pullRequests = await gitApi.getPullRequests(
92 | repositoryId,
93 | searchCriteria,
94 | projectId,
95 | undefined, // maxCommentLength
96 | skip,
97 | top,
98 | );
99 |
100 | const results = pullRequests || [];
101 | const count = results.length;
102 |
103 | // Determine if there are likely more results
104 | // If we got exactly the number requested, there are probably more
105 | const hasMoreResults = count === top;
106 |
107 | // Add a warning message if results were truncated
108 | let warning: string | undefined;
109 | if (hasMoreResults) {
110 | warning = `Results limited to ${top} items. Use 'skip: ${skip + top}' to get the next page.`;
111 | }
112 |
113 | return {
114 | count,
115 | value: results,
116 | hasMoreResults,
117 | warning,
118 | };
119 | } catch (error) {
120 | if (error instanceof AzureDevOpsError) {
121 | throw error;
122 | }
123 | throw new Error(
124 | `Failed to list pull requests: ${error instanceof Error ? error.message : String(error)}`,
125 | );
126 | }
127 | }
128 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-changes/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | GitPullRequestIterationChanges,
4 | GitChange,
5 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
6 | import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
7 | import { AzureDevOpsError } from '../../../shared/errors';
8 | import { createTwoFilesPatch } from 'diff';
9 |
10 | export interface PullRequestChangesOptions {
11 | projectId: string;
12 | repositoryId: string;
13 | pullRequestId: number;
14 | }
15 |
16 | export interface PullRequestChangesResponse {
17 | changes: GitPullRequestIterationChanges;
18 | evaluations: PolicyEvaluationRecord[];
19 | files: Array<{ path: string; patch: string }>;
20 | sourceRefName?: string;
21 | targetRefName?: string;
22 | }
23 |
24 | /**
25 | * Retrieve changes and policy evaluation status for a pull request
26 | */
27 | export async function getPullRequestChanges(
28 | connection: WebApi,
29 | options: PullRequestChangesOptions,
30 | ): Promise<PullRequestChangesResponse> {
31 | try {
32 | const gitApi = await connection.getGitApi();
33 | const [pullRequest, iterations] = await Promise.all([
34 | gitApi.getPullRequest(
35 | options.repositoryId,
36 | options.pullRequestId,
37 | options.projectId,
38 | ),
39 | gitApi.getPullRequestIterations(
40 | options.repositoryId,
41 | options.pullRequestId,
42 | options.projectId,
43 | ),
44 | ]);
45 | if (!iterations || iterations.length === 0) {
46 | throw new AzureDevOpsError('No iterations found for pull request');
47 | }
48 | const latest = iterations[iterations.length - 1];
49 | const changes = await gitApi.getPullRequestIterationChanges(
50 | options.repositoryId,
51 | options.pullRequestId,
52 | latest.id!,
53 | options.projectId,
54 | );
55 |
56 | const policyApi = await connection.getPolicyApi();
57 | const artifactId = `vstfs:///CodeReview/CodeReviewId/${options.projectId}/${options.pullRequestId}`;
58 | const evaluations = await policyApi.getPolicyEvaluations(
59 | options.projectId,
60 | artifactId,
61 | );
62 |
63 | const changeEntries = changes.changeEntries ?? [];
64 |
65 | const getBlobText = async (objId?: string): Promise<string> => {
66 | if (!objId) return '';
67 | const stream = await gitApi.getBlobContent(
68 | options.repositoryId,
69 | objId,
70 | options.projectId,
71 | );
72 |
73 | const chunks: Uint8Array[] = [];
74 | return await new Promise<string>((resolve, reject) => {
75 | stream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
76 | stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
77 | stream.on('error', reject);
78 | });
79 | };
80 |
81 | const files = await Promise.all(
82 | changeEntries.map(async (entry: GitChange) => {
83 | const path = entry.item?.path || entry.originalPath || '';
84 | const [oldContent, newContent] = await Promise.all([
85 | getBlobText(entry.item?.originalObjectId),
86 | getBlobText(entry.item?.objectId),
87 | ]);
88 | const patch = createTwoFilesPatch(
89 | entry.originalPath || path,
90 | path,
91 | oldContent,
92 | newContent,
93 | );
94 | return { path, patch };
95 | }),
96 | );
97 |
98 | return {
99 | changes,
100 | evaluations,
101 | files,
102 | sourceRefName: pullRequest?.sourceRefName,
103 | targetRefName: pullRequest?.targetRefName,
104 | };
105 | } catch (error) {
106 | if (error instanceof AzureDevOpsError) {
107 | throw error;
108 | }
109 | throw new Error(
110 | `Failed to get pull request changes: ${error instanceof Error ? error.message : String(error)}`,
111 | );
112 | }
113 | }
114 |
```