This is page 7 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/search/search-work-items/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import axios from 'axios';
3 | import { searchWorkItems } from './feature';
4 | import {
5 | AzureDevOpsError,
6 | AzureDevOpsResourceNotFoundError,
7 | AzureDevOpsValidationError,
8 | AzureDevOpsPermissionError,
9 | } from '../../../shared/errors';
10 | import { SearchWorkItemsOptions, WorkItemSearchResponse } from '../types';
11 |
12 | // Mock axios
13 | jest.mock('axios');
14 | const mockedAxios = axios as jest.Mocked<typeof axios>;
15 |
16 | // Mock @azure/identity
17 | jest.mock('@azure/identity', () => ({
18 | DefaultAzureCredential: jest.fn().mockImplementation(() => ({
19 | getToken: jest
20 | .fn()
21 | .mockResolvedValue({ token: 'mock-azure-identity-token' }),
22 | })),
23 | AzureCliCredential: jest.fn(),
24 | }));
25 |
26 | // Mock WebApi
27 | jest.mock('azure-devops-node-api');
28 | const MockedWebApi = WebApi as jest.MockedClass<typeof WebApi>;
29 |
30 | describe('searchWorkItems', () => {
31 | let connection: WebApi;
32 | let options: SearchWorkItemsOptions;
33 | let mockResponse: WorkItemSearchResponse;
34 |
35 | beforeEach(() => {
36 | // Reset mocks
37 | jest.clearAllMocks();
38 |
39 | // Mock environment variables
40 | process.env.AZURE_DEVOPS_AUTH_METHOD = 'pat';
41 | process.env.AZURE_DEVOPS_PAT = 'mock-pat';
42 |
43 | // Set up connection mock
44 | // Create a mock auth handler that implements IRequestHandler
45 | const mockAuthHandler = {
46 | prepareRequest: jest.fn(),
47 | canHandleAuthentication: jest.fn().mockReturnValue(true),
48 | handleAuthentication: jest.fn(),
49 | };
50 | connection = new MockedWebApi(
51 | 'https://dev.azure.com/mock-org',
52 | mockAuthHandler,
53 | );
54 | (connection as any).serverUrl = 'https://dev.azure.com/mock-org';
55 | (connection.getCoreApi as jest.Mock).mockResolvedValue({
56 | getProjects: jest.fn().mockResolvedValue([]),
57 | });
58 |
59 | // Set up options
60 | options = {
61 | searchText: 'test query',
62 | projectId: 'mock-project',
63 | top: 50,
64 | skip: 0,
65 | includeFacets: true,
66 | };
67 |
68 | // Set up mock response
69 | mockResponse = {
70 | count: 2,
71 | results: [
72 | {
73 | project: {
74 | id: 'project-id-1',
75 | name: 'mock-project',
76 | },
77 | fields: {
78 | 'system.id': '42',
79 | 'system.workitemtype': 'Bug',
80 | 'system.title': 'Test Bug',
81 | 'system.state': 'Active',
82 | 'system.assignedto': 'Test User',
83 | },
84 | hits: [
85 | {
86 | fieldReferenceName: 'system.title',
87 | highlights: ['Test <b>Bug</b>'],
88 | },
89 | ],
90 | url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/42',
91 | },
92 | {
93 | project: {
94 | id: 'project-id-1',
95 | name: 'mock-project',
96 | },
97 | fields: {
98 | 'system.id': '43',
99 | 'system.workitemtype': 'Task',
100 | 'system.title': 'Test Task',
101 | 'system.state': 'New',
102 | 'system.assignedto': 'Test User',
103 | },
104 | hits: [
105 | {
106 | fieldReferenceName: 'system.title',
107 | highlights: ['Test <b>Task</b>'],
108 | },
109 | ],
110 | url: 'https://dev.azure.com/mock-org/mock-project/_workitems/edit/43',
111 | },
112 | ],
113 | facets: {
114 | 'System.WorkItemType': [
115 | {
116 | name: 'Bug',
117 | id: 'Bug',
118 | resultCount: 1,
119 | },
120 | {
121 | name: 'Task',
122 | id: 'Task',
123 | resultCount: 1,
124 | },
125 | ],
126 | },
127 | };
128 |
129 | // Mock axios response
130 | mockedAxios.post.mockResolvedValue({ data: mockResponse });
131 | });
132 |
133 | afterEach(() => {
134 | // Clean up environment variables
135 | delete process.env.AZURE_DEVOPS_AUTH_METHOD;
136 | delete process.env.AZURE_DEVOPS_PAT;
137 | });
138 |
139 | it('should search work items with the correct parameters', async () => {
140 | // Act
141 | const result = await searchWorkItems(connection, options);
142 |
143 | // Assert
144 | expect(mockedAxios.post).toHaveBeenCalledWith(
145 | 'https://almsearch.dev.azure.com/mock-org/mock-project/_apis/search/workitemsearchresults?api-version=7.1',
146 | {
147 | searchText: 'test query',
148 | $skip: 0,
149 | $top: 50,
150 | filters: {
151 | 'System.TeamProject': ['mock-project'],
152 | },
153 | includeFacets: true,
154 | },
155 | expect.objectContaining({
156 | headers: expect.objectContaining({
157 | Authorization: expect.stringContaining('Basic'),
158 | 'Content-Type': 'application/json',
159 | }),
160 | }),
161 | );
162 | expect(result).toEqual(mockResponse);
163 | });
164 |
165 | it('should include filters when provided', async () => {
166 | // Arrange
167 | options.filters = {
168 | 'System.WorkItemType': ['Bug', 'Task'],
169 | 'System.State': ['Active'],
170 | };
171 |
172 | // Act
173 | await searchWorkItems(connection, options);
174 |
175 | // Assert
176 | expect(mockedAxios.post).toHaveBeenCalledWith(
177 | expect.any(String),
178 | expect.objectContaining({
179 | filters: {
180 | 'System.TeamProject': ['mock-project'],
181 | 'System.WorkItemType': ['Bug', 'Task'],
182 | 'System.State': ['Active'],
183 | },
184 | }),
185 | expect.any(Object),
186 | );
187 | });
188 |
189 | it('should include orderBy when provided', async () => {
190 | // Arrange
191 | options.orderBy = [{ field: 'System.CreatedDate', sortOrder: 'ASC' }];
192 |
193 | // Act
194 | await searchWorkItems(connection, options);
195 |
196 | // Assert
197 | expect(mockedAxios.post).toHaveBeenCalledWith(
198 | expect.any(String),
199 | expect.objectContaining({
200 | $orderBy: [{ field: 'System.CreatedDate', sortOrder: 'ASC' }],
201 | }),
202 | expect.any(Object),
203 | );
204 | });
205 |
206 | it('should handle 404 errors correctly', async () => {
207 | // Arrange - Mock the implementation to throw the specific error
208 | mockedAxios.post.mockImplementation(() => {
209 | throw new AzureDevOpsResourceNotFoundError(
210 | 'Resource not found: Project not found',
211 | );
212 | });
213 |
214 | // Act & Assert
215 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
216 | AzureDevOpsResourceNotFoundError,
217 | );
218 | });
219 |
220 | it('should handle 400 errors correctly', async () => {
221 | // Arrange - Mock the implementation to throw the specific error
222 | mockedAxios.post.mockImplementation(() => {
223 | throw new AzureDevOpsValidationError('Invalid request: Invalid query');
224 | });
225 |
226 | // Act & Assert
227 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
228 | AzureDevOpsValidationError,
229 | );
230 | });
231 |
232 | it('should handle 401/403 errors correctly', async () => {
233 | // Arrange - Mock the implementation to throw the specific error
234 | mockedAxios.post.mockImplementation(() => {
235 | throw new AzureDevOpsPermissionError(
236 | 'Permission denied: Permission denied',
237 | );
238 | });
239 |
240 | // Act & Assert
241 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
242 | AzureDevOpsPermissionError,
243 | );
244 | });
245 |
246 | it('should handle other axios errors correctly', async () => {
247 | // Arrange - Mock the implementation to throw the specific error
248 | mockedAxios.post.mockImplementation(() => {
249 | throw new AzureDevOpsError(
250 | 'Azure DevOps API error: Internal server error',
251 | );
252 | });
253 |
254 | // Act & Assert
255 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
256 | AzureDevOpsError,
257 | );
258 | });
259 |
260 | it('should handle non-axios errors correctly', async () => {
261 | // Arrange
262 | mockedAxios.post.mockRejectedValue(new Error('Network error'));
263 |
264 | // Act & Assert
265 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
266 | AzureDevOpsError,
267 | );
268 | });
269 |
270 | it('should throw an error if organization cannot be extracted', async () => {
271 | // Arrange
272 | (connection as any).serverUrl = 'https://invalid-url';
273 |
274 | // Act & Assert
275 | await expect(searchWorkItems(connection, options)).rejects.toThrow(
276 | AzureDevOpsValidationError,
277 | );
278 | });
279 |
280 | it('should use Azure Identity authentication when AZURE_DEVOPS_AUTH_METHOD is azure-identity', async () => {
281 | // Mock environment variables
282 | const originalEnv = process.env.AZURE_DEVOPS_AUTH_METHOD;
283 | process.env.AZURE_DEVOPS_AUTH_METHOD = 'azure-identity';
284 |
285 | // Mock the WebApi connection
286 | const mockConnection = {
287 | serverUrl: 'https://dev.azure.com/testorg',
288 | getCoreApi: jest.fn().mockResolvedValue({
289 | getProjects: jest.fn().mockResolvedValue([]),
290 | }),
291 | };
292 |
293 | // Mock axios post
294 | const mockResponse = {
295 | data: {
296 | count: 0,
297 | results: [],
298 | },
299 | };
300 | (axios.post as jest.Mock).mockResolvedValueOnce(mockResponse);
301 |
302 | // Call the function
303 | await searchWorkItems(mockConnection as unknown as WebApi, {
304 | projectId: 'testproject',
305 | searchText: 'test query',
306 | });
307 |
308 | // Verify the axios post was called with a Bearer token
309 | expect(axios.post).toHaveBeenCalledWith(
310 | expect.any(String),
311 | expect.any(Object),
312 | {
313 | headers: {
314 | Authorization: 'Bearer mock-azure-identity-token',
315 | 'Content-Type': 'application/json',
316 | },
317 | },
318 | );
319 |
320 | // Cleanup
321 | process.env.AZURE_DEVOPS_AUTH_METHOD = originalEnv;
322 | });
323 |
324 | test('should perform organization-wide work item search when projectId is not provided', async () => {
325 | // Arrange
326 | const mockSearchResponse = {
327 | data: {
328 | count: 2,
329 | results: [
330 | {
331 | id: 1,
332 | fields: {
333 | 'System.Title': 'Test Bug 1',
334 | 'System.State': 'Active',
335 | 'System.WorkItemType': 'Bug',
336 | 'System.TeamProject': 'Project1',
337 | },
338 | project: {
339 | name: 'Project1',
340 | id: 'project-id-1',
341 | },
342 | },
343 | {
344 | id: 2,
345 | fields: {
346 | 'System.Title': 'Test Bug 2',
347 | 'System.State': 'Active',
348 | 'System.WorkItemType': 'Bug',
349 | 'System.TeamProject': 'Project2',
350 | },
351 | project: {
352 | name: 'Project2',
353 | id: 'project-id-2',
354 | },
355 | },
356 | ],
357 | },
358 | };
359 |
360 | mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
361 |
362 | // Act
363 | const result = await searchWorkItems(connection, {
364 | searchText: 'bug',
365 | });
366 |
367 | // Assert
368 | expect(result).toBeDefined();
369 | expect(result.count).toBe(2);
370 | expect(result.results).toHaveLength(2);
371 | expect(result.results[0].fields['System.TeamProject']).toBe('Project1');
372 | expect(result.results[1].fields['System.TeamProject']).toBe('Project2');
373 | expect(mockedAxios.post).toHaveBeenCalledTimes(1);
374 | expect(mockedAxios.post).toHaveBeenCalledWith(
375 | expect.stringContaining(
376 | 'https://almsearch.dev.azure.com/mock-org/_apis/search/workitemsearchresults',
377 | ),
378 | expect.not.objectContaining({
379 | filters: expect.objectContaining({
380 | 'System.TeamProject': expect.anything(),
381 | }),
382 | }),
383 | expect.any(Object),
384 | );
385 | });
386 | });
387 |
```
--------------------------------------------------------------------------------
/src/features/search/search-code/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { searchCode } from './feature';
3 | import {
4 | getTestConnection,
5 | shouldSkipIntegrationTest,
6 | } from '@/shared/test/test-helpers';
7 | import { SearchCodeOptions } from '../types';
8 |
9 | describe('searchCode 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 search code in a project', async () => {
20 | // Skip if no connection is available
21 | if (shouldSkipIntegrationTest()) {
22 | console.log('Skipping test: No Azure DevOps connection available');
23 | return;
24 | }
25 |
26 | // This connection must be available if we didn't skip
27 | if (!connection) {
28 | throw new Error(
29 | 'Connection should be available when test is not skipped',
30 | );
31 | }
32 |
33 | const options: SearchCodeOptions = {
34 | searchText: 'function',
35 | projectId: projectName,
36 | top: 10,
37 | };
38 |
39 | try {
40 | // Act - make an actual API call to Azure DevOps
41 | const result = await searchCode(connection, options);
42 |
43 | // Assert on the actual response
44 | expect(result).toBeDefined();
45 | expect(typeof result.count).toBe('number');
46 | expect(Array.isArray(result.results)).toBe(true);
47 |
48 | // Check structure of returned items (if any)
49 | if (result.results.length > 0) {
50 | const firstResult = result.results[0];
51 | expect(firstResult.fileName).toBeDefined();
52 | expect(firstResult.path).toBeDefined();
53 | expect(firstResult.project).toBeDefined();
54 | expect(firstResult.repository).toBeDefined();
55 |
56 | if (firstResult.project) {
57 | expect(firstResult.project.name).toBe(projectName);
58 | }
59 | }
60 | } catch (error) {
61 | // Skip test if the code search extension is not installed
62 | if (
63 | error instanceof Error &&
64 | (error.message.includes('ms.vss-code-search is not installed') ||
65 | error.message.includes('Resource not found') ||
66 | error.message.includes('Failed to search code'))
67 | ) {
68 | console.log(
69 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization',
70 | );
71 | return;
72 | }
73 | throw error;
74 | }
75 | });
76 |
77 | test('should include file content when requested', async () => {
78 | // Skip if no connection is available
79 | if (shouldSkipIntegrationTest()) {
80 | console.log('Skipping test: No Azure DevOps connection available');
81 | return;
82 | }
83 |
84 | // This connection must be available if we didn't skip
85 | if (!connection) {
86 | throw new Error(
87 | 'Connection should be available when test is not skipped',
88 | );
89 | }
90 |
91 | const options: SearchCodeOptions = {
92 | searchText: 'function',
93 | projectId: projectName,
94 | top: 5,
95 | includeContent: true,
96 | };
97 |
98 | try {
99 | // Act - make an actual API call to Azure DevOps
100 | const result = await searchCode(connection, options);
101 |
102 | // Assert on the actual response
103 | expect(result).toBeDefined();
104 |
105 | // Check if content is included (if any results)
106 | if (result.results.length > 0) {
107 | // At least some results should have content
108 | // Note: Some files might fail to fetch content, so we don't expect all to have it
109 | const hasContent = result.results.some((r) => r.content !== undefined);
110 | expect(hasContent).toBe(true);
111 | }
112 | } catch (error) {
113 | // Skip test if the code search extension is not installed
114 | if (
115 | error instanceof Error &&
116 | (error.message.includes('ms.vss-code-search is not installed') ||
117 | error.message.includes('Resource not found') ||
118 | error.message.includes('Failed to search code'))
119 | ) {
120 | console.log(
121 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization',
122 | );
123 | return;
124 | }
125 | throw error;
126 | }
127 | });
128 |
129 | test('should filter results when filters are provided', async () => {
130 | // Skip if no connection is available
131 | if (shouldSkipIntegrationTest()) {
132 | console.log('Skipping test: No Azure DevOps connection available');
133 | return;
134 | }
135 |
136 | // This connection must be available if we didn't skip
137 | if (!connection) {
138 | throw new Error(
139 | 'Connection should be available when test is not skipped',
140 | );
141 | }
142 |
143 | try {
144 | // First get some results to find a repository name
145 | const initialOptions: SearchCodeOptions = {
146 | searchText: 'function',
147 | projectId: projectName,
148 | top: 1,
149 | };
150 |
151 | const initialResult = await searchCode(connection, initialOptions);
152 |
153 | // Skip if no results found
154 | if (initialResult.results.length === 0) {
155 | console.log('Skipping filter test: No initial results found');
156 | return;
157 | }
158 |
159 | // Use the repository from the first result for filtering
160 | const repoName = initialResult.results[0].repository.name;
161 |
162 | const filteredOptions: SearchCodeOptions = {
163 | searchText: 'function',
164 | projectId: projectName,
165 | filters: {
166 | Repository: [repoName],
167 | },
168 | top: 5,
169 | };
170 |
171 | // Act - make an actual API call to Azure DevOps with filters
172 | const result = await searchCode(connection, filteredOptions);
173 |
174 | // Assert on the actual response
175 | expect(result).toBeDefined();
176 |
177 | // All results should be from the specified repository
178 | if (result.results.length > 0) {
179 | const allFromRepo = result.results.every(
180 | (r) => r.repository.name === repoName,
181 | );
182 | expect(allFromRepo).toBe(true);
183 | }
184 | } catch (error) {
185 | // Skip test if the code search extension is not installed
186 | if (
187 | error instanceof Error &&
188 | (error.message.includes('ms.vss-code-search is not installed') ||
189 | error.message.includes('Resource not found') ||
190 | error.message.includes('Failed to search code'))
191 | ) {
192 | console.log(
193 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization',
194 | );
195 | return;
196 | }
197 | throw error;
198 | }
199 | });
200 |
201 | test('should handle pagination', async () => {
202 | // Skip if no connection is available
203 | if (shouldSkipIntegrationTest()) {
204 | console.log('Skipping test: No Azure DevOps connection available');
205 | return;
206 | }
207 |
208 | // This connection must be available if we didn't skip
209 | if (!connection) {
210 | throw new Error(
211 | 'Connection should be available when test is not skipped',
212 | );
213 | }
214 |
215 | try {
216 | // Get first page
217 | const firstPageOptions: SearchCodeOptions = {
218 | searchText: 'function',
219 | projectId: projectName,
220 | top: 2,
221 | skip: 0,
222 | };
223 |
224 | const firstPageResult = await searchCode(connection, firstPageOptions);
225 |
226 | // Skip if not enough results for pagination test
227 | if (firstPageResult.count <= 2) {
228 | console.log('Skipping pagination test: Not enough results');
229 | return;
230 | }
231 |
232 | // Get second page
233 | const secondPageOptions: SearchCodeOptions = {
234 | searchText: 'function',
235 | projectId: projectName,
236 | top: 2,
237 | skip: 2,
238 | };
239 |
240 | const secondPageResult = await searchCode(connection, secondPageOptions);
241 |
242 | // Assert on pagination
243 | expect(secondPageResult).toBeDefined();
244 | expect(secondPageResult.results.length).toBeGreaterThan(0);
245 |
246 | // First and second page should have different results
247 | if (
248 | firstPageResult.results.length > 0 &&
249 | secondPageResult.results.length > 0
250 | ) {
251 | const firstPagePaths = firstPageResult.results.map((r) => r.path);
252 | const secondPagePaths = secondPageResult.results.map((r) => r.path);
253 |
254 | // Check if there's any overlap between pages
255 | const hasOverlap = firstPagePaths.some((path) =>
256 | secondPagePaths.includes(path),
257 | );
258 | expect(hasOverlap).toBe(false);
259 | }
260 | } catch (error) {
261 | // Skip test if the code search extension is not installed
262 | if (
263 | error instanceof Error &&
264 | (error.message.includes('ms.vss-code-search is not installed') ||
265 | error.message.includes('Resource not found') ||
266 | error.message.includes('Failed to search code'))
267 | ) {
268 | console.log(
269 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization',
270 | );
271 | return;
272 | }
273 | throw error;
274 | }
275 | });
276 |
277 | test('should use default project when no projectId is provided', async () => {
278 | // Skip if no connection is available
279 | if (shouldSkipIntegrationTest()) {
280 | console.log('Skipping test: No Azure DevOps connection available');
281 | return;
282 | }
283 |
284 | // This connection must be available if we didn't skip
285 | if (!connection) {
286 | throw new Error(
287 | 'Connection should be available when test is not skipped',
288 | );
289 | }
290 |
291 | // Store original environment variable
292 | const originalEnv = process.env.AZURE_DEVOPS_DEFAULT_PROJECT;
293 |
294 | try {
295 | // Set the default project to the current project name for testing
296 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = projectName;
297 |
298 | // Search without specifying a project ID
299 | const options: SearchCodeOptions = {
300 | searchText: 'function',
301 | top: 5,
302 | };
303 |
304 | // Act - make an actual API call to Azure DevOps
305 | const result = await searchCode(connection, options);
306 |
307 | // Assert on the actual response
308 | expect(result).toBeDefined();
309 | expect(typeof result.count).toBe('number');
310 | expect(Array.isArray(result.results)).toBe(true);
311 |
312 | // Check structure of returned items (if any)
313 | if (result.results.length > 0) {
314 | const firstResult = result.results[0];
315 | expect(firstResult.fileName).toBeDefined();
316 | expect(firstResult.path).toBeDefined();
317 | expect(firstResult.project).toBeDefined();
318 | expect(firstResult.repository).toBeDefined();
319 |
320 | if (firstResult.project) {
321 | expect(firstResult.project.name).toBe(projectName);
322 | }
323 | }
324 | } catch (error) {
325 | // Skip test if the code search extension is not installed
326 | if (
327 | error instanceof Error &&
328 | (error.message.includes('ms.vss-code-search is not installed') ||
329 | error.message.includes('Resource not found') ||
330 | error.message.includes('Failed to search code'))
331 | ) {
332 | console.log(
333 | 'Skipping test: Code Search extension is not installed or not available in this Azure DevOps organization',
334 | );
335 | return;
336 | }
337 | throw error;
338 | } finally {
339 | // Restore original environment variable
340 | process.env.AZURE_DEVOPS_DEFAULT_PROJECT = originalEnv;
341 | }
342 | });
343 | });
344 |
```
--------------------------------------------------------------------------------
/src/features/repositories/schemas.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { z } from 'zod';
2 | import { defaultProject, defaultOrg } from '../../utils/environment';
3 |
4 | /**
5 | * Schema for getting a repository
6 | */
7 | export const GetRepositorySchema = z.object({
8 | projectId: z
9 | .string()
10 | .optional()
11 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
12 | organizationId: z
13 | .string()
14 | .optional()
15 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
16 | repositoryId: z.string().describe('The ID or name of the repository'),
17 | });
18 |
19 | /**
20 | * Schema for getting detailed repository information
21 | */
22 | export const GetRepositoryDetailsSchema = z.object({
23 | projectId: z
24 | .string()
25 | .optional()
26 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
27 | organizationId: z
28 | .string()
29 | .optional()
30 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
31 | repositoryId: z.string().describe('The ID or name of the repository'),
32 | includeStatistics: z
33 | .boolean()
34 | .optional()
35 | .default(false)
36 | .describe('Whether to include branch statistics'),
37 | includeRefs: z
38 | .boolean()
39 | .optional()
40 | .default(false)
41 | .describe('Whether to include repository refs'),
42 | refFilter: z
43 | .string()
44 | .optional()
45 | .describe('Optional filter for refs (e.g., "heads/" or "tags/")'),
46 | branchName: z
47 | .string()
48 | .optional()
49 | .describe(
50 | 'Name of specific branch to get statistics for (if includeStatistics is true)',
51 | ),
52 | });
53 |
54 | /**
55 | * Schema for listing repositories
56 | */
57 | export const ListRepositoriesSchema = z.object({
58 | projectId: z
59 | .string()
60 | .optional()
61 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
62 | organizationId: z
63 | .string()
64 | .optional()
65 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
66 | includeLinks: z
67 | .boolean()
68 | .optional()
69 | .describe('Whether to include reference links'),
70 | });
71 |
72 | /**
73 | * Schema for getting file content
74 | */
75 | export const GetFileContentSchema = z.object({
76 | projectId: z
77 | .string()
78 | .optional()
79 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
80 | organizationId: z
81 | .string()
82 | .optional()
83 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
84 | repositoryId: z.string().describe('The ID or name of the repository'),
85 | path: z
86 | .string()
87 | .optional()
88 | .default('/')
89 | .describe('Path to the file or folder'),
90 | version: z
91 | .string()
92 | .optional()
93 | .describe('The version (branch, tag, or commit) to get content from'),
94 | versionType: z
95 | .enum(['branch', 'commit', 'tag'])
96 | .optional()
97 | .describe('Type of version specified (branch, commit, or tag)'),
98 | });
99 |
100 | /**
101 | * Schema for getting all repositories tree structure
102 | */
103 | export const GetAllRepositoriesTreeSchema = z.object({
104 | organizationId: z
105 | .string()
106 | .optional()
107 | .describe(
108 | `The ID or name of the Azure DevOps organization (Default: ${defaultOrg})`,
109 | ),
110 | projectId: z
111 | .string()
112 | .optional()
113 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
114 | repositoryPattern: z
115 | .string()
116 | .optional()
117 | .describe(
118 | 'Repository name pattern (wildcard characters allowed) to filter which repositories are included',
119 | ),
120 | depth: z
121 | .number()
122 | .int()
123 | .min(0)
124 | .max(10)
125 | .optional()
126 | .default(0)
127 | .describe(
128 | 'Maximum depth to traverse within each repository (0 = unlimited)',
129 | ),
130 | pattern: z
131 | .string()
132 | .optional()
133 | .describe(
134 | 'File pattern (wildcard characters allowed) to filter files by within each repository',
135 | ),
136 | });
137 |
138 | /**
139 | * Schema for getting a tree for a single repository
140 | */
141 | export const GetRepositoryTreeSchema = z.object({
142 | projectId: z
143 | .string()
144 | .optional()
145 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
146 | organizationId: z
147 | .string()
148 | .optional()
149 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
150 | repositoryId: z.string().describe('The ID or name of the repository'),
151 | path: z
152 | .string()
153 | .optional()
154 | .default('/')
155 | .describe('Path within the repository to start from'),
156 | depth: z
157 | .number()
158 | .int()
159 | .min(0)
160 | .max(10)
161 | .optional()
162 | .default(0)
163 | .describe('Maximum depth to traverse (0 = unlimited)'),
164 | });
165 |
166 | /**
167 | * Schema for creating a new branch
168 | */
169 | export const CreateBranchSchema = z
170 | .object({
171 | projectId: z
172 | .string()
173 | .optional()
174 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
175 | organizationId: z
176 | .string()
177 | .optional()
178 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
179 | repositoryId: z.string().describe('The ID or name of the repository'),
180 | sourceBranch: z
181 | .string()
182 | .describe(
183 | 'Name of the branch to copy from (without "refs/heads/", e.g., "master")',
184 | ),
185 | newBranch: z
186 | .string()
187 | .describe(
188 | 'Name of the new branch to create (without "refs/heads/", e.g., "feature/my-branch")',
189 | ),
190 | })
191 | .describe(
192 | 'Create a new branch from an existing branch.\n' +
193 | '- Pass plain branch names (no "refs/heads/"). Example: sourceBranch="master", newBranch="codex/test1".\n' +
194 | '- When creating pull requests later, use fully-qualified refs (e.g., "refs/heads/codex/test1").',
195 | );
196 |
197 | /**
198 | * Schema for creating a commit with multiple file changes
199 | */
200 | export const CreateCommitSchema = z
201 | .object({
202 | projectId: z
203 | .string()
204 | .optional()
205 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
206 | organizationId: z
207 | .string()
208 | .optional()
209 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
210 | repositoryId: z.string().describe('The ID or name of the repository'),
211 | branchName: z
212 | .string()
213 | .describe(
214 | 'The branch to commit to (without "refs/heads/", e.g., "codex/test2-delete-main-py")',
215 | ),
216 | commitMessage: z.string().describe('Commit message'),
217 | changes: z
218 | .array(
219 | z
220 | .object({
221 | path: z
222 | .string()
223 | .optional()
224 | .describe(
225 | 'File path. Optional for patch format (uses diff header), REQUIRED for search/replace format',
226 | ),
227 | patch: z
228 | .string()
229 | .optional()
230 | .describe(
231 | [
232 | 'Unified git diff for a single file.',
233 | 'MUST include `diff --git`, `--- a/...`, `+++ b/...`, and complete hunk headers.',
234 | 'CRITICAL: Every hunk header must have line numbers in format: @@ -oldStart,oldLines +newStart,newLines @@',
235 | 'Do NOT use @@ without the line range numbers - this will cause parsing failures.',
236 | 'Include 3-5 context lines before and after changes for proper patch application.',
237 | 'Use `/dev/null` with `---` for new files, or with `+++` for deleted files.',
238 | '',
239 | 'Example modify patch:',
240 | '```diff',
241 | 'diff --git a/charts/bcs-mcp-server/templates/service-api.yaml b/charts/bcs-mcp-server/templates/service-api.yaml',
242 | '--- a/charts/bcs-mcp-server/templates/service-api.yaml',
243 | '+++ b/charts/bcs-mcp-server/templates/service-api.yaml',
244 | '@@ -4,7 +4,7 @@ spec:',
245 | ' spec:',
246 | ' type: {{ .Values.service.type }}',
247 | ' ports:',
248 | '- - port: 8080',
249 | '+ - port: 9090',
250 | ' targetPort: deployment-port',
251 | ' protocol: TCP',
252 | ' name: http',
253 | '```',
254 | ].join('\n'),
255 | ),
256 | search: z
257 | .string()
258 | .optional()
259 | .describe(
260 | [
261 | 'Alternative to patch: Exact text to search for in the file.',
262 | 'Must be used with "replace" and "path" fields.',
263 | 'The server will fetch the file, perform the replacement, and generate the patch automatically.',
264 | 'This is MUCH EASIER than creating unified diffs manually - no line counting needed!',
265 | '',
266 | 'Example:',
267 | '"search": "return axios.post(apiUrl, payload, requestConfig);"',
268 | '"replace": "return axios.post(apiUrl, payload, requestConfig).then(r => { /* process */ return r; });"',
269 | ].join('\n'),
270 | ),
271 | replace: z
272 | .string()
273 | .optional()
274 | .describe(
275 | 'Alternative to patch: Exact text to replace the "search" string with. Must be used together with "search" and "path".',
276 | ),
277 | })
278 | .refine(
279 | (data) => {
280 | const hasPatch = !!data.patch;
281 | const hasSearchReplace = !!data.search && !!data.replace;
282 | return hasPatch || hasSearchReplace;
283 | },
284 | {
285 | message:
286 | 'Either "patch" or both "search" and "replace" must be provided',
287 | },
288 | ),
289 | )
290 | .describe(
291 | 'List of file changes as either unified git diffs OR search/replace pairs',
292 | ),
293 | })
294 | .describe(
295 | [
296 | 'Create a commit on an existing branch using file changes.',
297 | '- Provide plain branch names (no "refs/heads/").',
298 | '',
299 | '**RECOMMENDED: Use search/replace format (easier, no line counting needed!)**',
300 | '',
301 | 'Option 1 - Search/Replace (Easiest):',
302 | '```json',
303 | '{',
304 | ' "changes": [{',
305 | ' "path": "src/file.ts",',
306 | ' "search": "old code here",',
307 | ' "replace": "new code here"',
308 | ' }]',
309 | '}',
310 | '```',
311 | '',
312 | 'Option 2 - Unified Diff (Advanced):',
313 | '- Requires complete hunk headers: @@ -oldStart,oldLines +newStart,newLines @@',
314 | '- Include 3-5 context lines before/after changes',
315 | '- For deletions: --- a/file, +++ /dev/null',
316 | '- For additions: --- /dev/null, +++ b/file',
317 | ].join('\n'),
318 | );
319 |
320 | /**
321 | * Schema for listing commits on a branch
322 | */
323 | export const ListCommitsSchema = z.object({
324 | projectId: z
325 | .string()
326 | .optional()
327 | .describe(`The ID or name of the project (Default: ${defaultProject})`),
328 | organizationId: z
329 | .string()
330 | .optional()
331 | .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
332 | repositoryId: z.string().describe('The ID or name of the repository'),
333 | branchName: z.string().describe('Branch name to list commits from'),
334 | top: z
335 | .number()
336 | .int()
337 | .min(1)
338 | .max(100)
339 | .optional()
340 | .describe('Maximum number of commits to return (Default: 10)'),
341 | skip: z
342 | .number()
343 | .int()
344 | .min(0)
345 | .optional()
346 | .describe('Number of commits to skip from the newest'),
347 | });
348 |
```
--------------------------------------------------------------------------------
/src/features/search/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Options for searching code in Azure DevOps repositories
3 | */
4 | export interface SearchCodeOptions {
5 | searchText: string;
6 | projectId?: string;
7 | filters?: {
8 | Repository?: string[];
9 | Path?: string[];
10 | Branch?: string[];
11 | CodeElement?: string[];
12 | };
13 | top?: number;
14 | skip?: number;
15 | includeSnippet?: boolean;
16 | includeContent?: boolean;
17 | }
18 |
19 | /**
20 | * Request body for the Azure DevOps Search API
21 | */
22 | export interface CodeSearchRequest {
23 | searchText: string;
24 | $skip?: number;
25 | $top?: number;
26 | filters?: {
27 | Project?: string[];
28 | Repository?: string[];
29 | Path?: string[];
30 | Branch?: string[];
31 | CodeElement?: string[];
32 | };
33 | includeFacets?: boolean;
34 | includeSnippet?: boolean;
35 | }
36 |
37 | /**
38 | * Match information for search results
39 | */
40 | export interface CodeSearchMatch {
41 | charOffset: number;
42 | length: number;
43 | }
44 |
45 | /**
46 | * Collection information for search results
47 | */
48 | export interface CodeSearchCollection {
49 | name: string;
50 | }
51 |
52 | /**
53 | * Project information for search results
54 | */
55 | export interface CodeSearchProject {
56 | name: string;
57 | id: string;
58 | }
59 |
60 | /**
61 | * Repository information for search results
62 | */
63 | export interface CodeSearchRepository {
64 | name: string;
65 | id: string;
66 | type: string;
67 | }
68 |
69 | /**
70 | * Version information for search results
71 | */
72 | export interface CodeSearchVersion {
73 | branchName: string;
74 | changeId: string;
75 | }
76 |
77 | /**
78 | * Individual code search result
79 | */
80 | export interface CodeSearchResult {
81 | fileName: string;
82 | path: string;
83 | content?: string; // Added to store full file content
84 | matches: {
85 | content?: CodeSearchMatch[];
86 | fileName?: CodeSearchMatch[];
87 | };
88 | collection: CodeSearchCollection;
89 | project: CodeSearchProject;
90 | repository: CodeSearchRepository;
91 | versions: CodeSearchVersion[];
92 | contentId: string;
93 | }
94 |
95 | /**
96 | * Facet information for search results
97 | */
98 | export interface CodeSearchFacet {
99 | name: string;
100 | id: string;
101 | resultCount: number;
102 | }
103 |
104 | /**
105 | * Response from the Azure DevOps Search API
106 | */
107 | export interface CodeSearchResponse {
108 | count: number;
109 | results: CodeSearchResult[];
110 | infoCode?: number;
111 | facets?: {
112 | Project?: CodeSearchFacet[];
113 | Repository?: CodeSearchFacet[];
114 | Path?: CodeSearchFacet[];
115 | Branch?: CodeSearchFacet[];
116 | CodeElement?: CodeSearchFacet[];
117 | };
118 | }
119 |
120 | /**
121 | * Options for searching wiki pages in Azure DevOps projects
122 | */
123 | export interface SearchWikiOptions {
124 | /**
125 | * The text to search for within wiki pages
126 | */
127 | searchText: string;
128 |
129 | /**
130 | * The ID or name of the project to search in
131 | * If not provided, search will be performed across the entire organization
132 | */
133 | projectId?: string;
134 |
135 | /**
136 | * Optional filters to narrow search results
137 | */
138 | filters?: {
139 | /**
140 | * Filter by project names. Useful for cross-project searches.
141 | */
142 | Project?: string[];
143 | };
144 |
145 | /**
146 | * Number of results to return
147 | * @default 100
148 | * @minimum 1
149 | * @maximum 1000
150 | */
151 | top?: number;
152 |
153 | /**
154 | * Number of results to skip for pagination
155 | * @default 0
156 | * @minimum 0
157 | */
158 | skip?: number;
159 |
160 | /**
161 | * Whether to include faceting in results
162 | * @default true
163 | */
164 | includeFacets?: boolean;
165 | }
166 |
167 | /**
168 | * Request body for the Azure DevOps Wiki Search API
169 | */
170 | export interface WikiSearchRequest {
171 | /**
172 | * The search text to find in wiki pages
173 | */
174 | searchText: string;
175 |
176 | /**
177 | * Number of results to skip for pagination
178 | */
179 | $skip?: number;
180 |
181 | /**
182 | * Number of results to return
183 | */
184 | $top?: number;
185 |
186 | /**
187 | * Filters to be applied. Set to null if no filters are needed.
188 | */
189 | filters?: {
190 | /**
191 | * Filter by project names
192 | */
193 | Project?: string[];
194 | };
195 |
196 | /**
197 | * Options for sorting search results
198 | * If null, results are sorted by relevance
199 | */
200 | $orderBy?: SortOption[];
201 |
202 | /**
203 | * Whether to include faceting in the result
204 | * @default false
205 | */
206 | includeFacets?: boolean;
207 | }
208 |
209 | /**
210 | * Sort option for search results
211 | */
212 | export interface SortOption {
213 | /**
214 | * Field to sort by
215 | */
216 | field: string;
217 |
218 | /**
219 | * Sort direction
220 | */
221 | sortOrder: 'asc' | 'desc' | 'ASC' | 'DESC';
222 | }
223 |
224 | /**
225 | * Defines the matched terms in the field of the wiki result
226 | */
227 | export interface WikiHit {
228 | /**
229 | * Reference name of the highlighted field
230 | */
231 | fieldReferenceName: string;
232 |
233 | /**
234 | * Matched/highlighted snippets of the field
235 | */
236 | highlights: string[];
237 | }
238 |
239 | /**
240 | * Defines the wiki result that matched a wiki search request
241 | */
242 | export interface WikiResult {
243 | /**
244 | * Name of the result file
245 | */
246 | fileName: string;
247 |
248 | /**
249 | * Path at which result file is present
250 | */
251 | path: string;
252 |
253 | /**
254 | * Collection of the result file
255 | */
256 | collection: {
257 | /**
258 | * Name of the collection
259 | */
260 | name: string;
261 | };
262 |
263 | /**
264 | * Project details of the wiki document
265 | */
266 | project: {
267 | /**
268 | * ID of the project
269 | */
270 | id: string;
271 |
272 | /**
273 | * Name of the project
274 | */
275 | name: string;
276 |
277 | /**
278 | * Visibility of the project
279 | */
280 | visibility?: string;
281 | };
282 |
283 | /**
284 | * Wiki information for the result
285 | */
286 | wiki: {
287 | /**
288 | * ID of the wiki
289 | */
290 | id: string;
291 |
292 | /**
293 | * Mapped path for the wiki
294 | */
295 | mappedPath: string;
296 |
297 | /**
298 | * Name of the wiki
299 | */
300 | name: string;
301 |
302 | /**
303 | * Version for wiki
304 | */
305 | version: string;
306 | };
307 |
308 | /**
309 | * Content ID of the result file
310 | */
311 | contentId: string;
312 |
313 | /**
314 | * Highlighted snippets of fields that match the search request
315 | * The list is sorted by relevance of the snippets
316 | */
317 | hits: WikiHit[];
318 | }
319 |
320 | /**
321 | * Defines a wiki search response item
322 | */
323 | export interface WikiSearchResponse {
324 | /**
325 | * Total number of matched wiki documents
326 | */
327 | count: number;
328 |
329 | /**
330 | * List of top matched wiki documents
331 | */
332 | results: WikiResult[];
333 |
334 | /**
335 | * Numeric code indicating additional information:
336 | * 0 - Ok
337 | * 1 - Account is being reindexed
338 | * 2 - Account indexing has not started
339 | * 3 - Invalid Request
340 | * ... and others as defined in the API
341 | */
342 | infoCode?: number;
343 |
344 | /**
345 | * A dictionary storing an array of Filter objects against each facet
346 | */
347 | facets?: {
348 | /**
349 | * Project facets for filtering
350 | */
351 | Project?: CodeSearchFacet[];
352 | };
353 | }
354 |
355 | /**
356 | * Options for searching work items in Azure DevOps projects
357 | */
358 | export interface SearchWorkItemsOptions {
359 | /**
360 | * The text to search for within work items
361 | */
362 | searchText: string;
363 |
364 | /**
365 | * The ID or name of the project to search in
366 | * If not provided, search will be performed across the entire organization
367 | */
368 | projectId?: string;
369 |
370 | /**
371 | * Optional filters to narrow search results
372 | */
373 | filters?: {
374 | /**
375 | * Filter by project names. Useful for cross-project searches.
376 | */
377 | 'System.TeamProject'?: string[];
378 |
379 | /**
380 | * Filter by work item types (Bug, Task, User Story, etc.)
381 | */
382 | 'System.WorkItemType'?: string[];
383 |
384 | /**
385 | * Filter by work item states (New, Active, Closed, etc.)
386 | */
387 | 'System.State'?: string[];
388 |
389 | /**
390 | * Filter by assigned users
391 | */
392 | 'System.AssignedTo'?: string[];
393 |
394 | /**
395 | * Filter by area paths
396 | */
397 | 'System.AreaPath'?: string[];
398 | };
399 |
400 | /**
401 | * Number of results to return
402 | * @default 100
403 | * @minimum 1
404 | * @maximum 1000
405 | */
406 | top?: number;
407 |
408 | /**
409 | * Number of results to skip for pagination
410 | * @default 0
411 | * @minimum 0
412 | */
413 | skip?: number;
414 |
415 | /**
416 | * Whether to include faceting in results
417 | * @default true
418 | */
419 | includeFacets?: boolean;
420 |
421 | /**
422 | * Options for sorting search results
423 | * If null, results are sorted by relevance
424 | */
425 | orderBy?: SortOption[];
426 | }
427 |
428 | /**
429 | * Request body for the Azure DevOps Work Item Search API
430 | */
431 | export interface WorkItemSearchRequest {
432 | /**
433 | * The search text to find in work items
434 | */
435 | searchText: string;
436 |
437 | /**
438 | * Number of results to skip for pagination
439 | */
440 | $skip?: number;
441 |
442 | /**
443 | * Number of results to return
444 | */
445 | $top?: number;
446 |
447 | /**
448 | * Filters to be applied. Set to null if no filters are needed.
449 | */
450 | filters?: {
451 | 'System.TeamProject'?: string[];
452 | 'System.WorkItemType'?: string[];
453 | 'System.State'?: string[];
454 | 'System.AssignedTo'?: string[];
455 | 'System.AreaPath'?: string[];
456 | };
457 |
458 | /**
459 | * Options for sorting search results
460 | * If null, results are sorted by relevance
461 | */
462 | $orderBy?: SortOption[];
463 |
464 | /**
465 | * Whether to include faceting in the result
466 | * @default false
467 | */
468 | includeFacets?: boolean;
469 | }
470 |
471 | /**
472 | * Defines the matched terms in the field of the work item result
473 | */
474 | export interface WorkItemHit {
475 | /**
476 | * Reference name of the highlighted field
477 | */
478 | fieldReferenceName: string;
479 |
480 | /**
481 | * Matched/highlighted snippets of the field
482 | */
483 | highlights: string[];
484 | }
485 |
486 | /**
487 | * Defines the work item result that matched a work item search request
488 | */
489 | export interface WorkItemResult {
490 | /**
491 | * Project details of the work item
492 | */
493 | project: {
494 | /**
495 | * ID of the project
496 | */
497 | id: string;
498 |
499 | /**
500 | * Name of the project
501 | */
502 | name: string;
503 | };
504 |
505 | /**
506 | * A standard set of work item fields and their values
507 | */
508 | fields: {
509 | /**
510 | * ID of the work item
511 | */
512 | 'system.id': string;
513 |
514 | /**
515 | * Type of the work item (Bug, Task, User Story, etc.)
516 | */
517 | 'system.workitemtype': string;
518 |
519 | /**
520 | * Title of the work item
521 | */
522 | 'system.title': string;
523 |
524 | /**
525 | * User assigned to the work item
526 | */
527 | 'system.assignedto'?: string;
528 |
529 | /**
530 | * Current state of the work item
531 | */
532 | 'system.state'?: string;
533 |
534 | /**
535 | * Tags associated with the work item
536 | */
537 | 'system.tags'?: string;
538 |
539 | /**
540 | * Revision number of the work item
541 | */
542 | 'system.rev'?: string;
543 |
544 | /**
545 | * Creation date of the work item
546 | */
547 | 'system.createddate'?: string;
548 |
549 | /**
550 | * Last modified date of the work item
551 | */
552 | 'system.changeddate'?: string;
553 |
554 | /**
555 | * Other fields may be included based on the work item type
556 | */
557 | [key: string]: string | number | boolean | null | undefined;
558 | };
559 |
560 | /**
561 | * Highlighted snippets of fields that match the search request
562 | * The list is sorted by relevance of the snippets
563 | */
564 | hits: WorkItemHit[];
565 |
566 | /**
567 | * URL to the work item
568 | */
569 | url: string;
570 | }
571 |
572 | /**
573 | * Defines a work item search response item
574 | */
575 | export interface WorkItemSearchResponse {
576 | /**
577 | * Total number of matched work items
578 | */
579 | count: number;
580 |
581 | /**
582 | * List of top matched work items
583 | */
584 | results: WorkItemResult[];
585 |
586 | /**
587 | * Numeric code indicating additional information:
588 | * 0 - Ok
589 | * 1 - Account is being reindexed
590 | * 2 - Account indexing has not started
591 | * 3 - Invalid Request
592 | * ... and others as defined in the API
593 | */
594 | infoCode?: number;
595 |
596 | /**
597 | * A dictionary storing an array of Filter objects against each facet
598 | */
599 | facets?: {
600 | 'System.TeamProject'?: CodeSearchFacet[];
601 | 'System.WorkItemType'?: CodeSearchFacet[];
602 | 'System.State'?: CodeSearchFacet[];
603 | 'System.AssignedTo'?: CodeSearchFacet[];
604 | 'System.AreaPath'?: CodeSearchFacet[];
605 | };
606 | }
607 |
```
--------------------------------------------------------------------------------
/setup_env.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Global variable to track if an error has occurred
4 | ERROR_OCCURRED=0
5 |
6 | # Function to handle errors without exiting the shell when sourced
7 | handle_error() {
8 | local message=$1
9 | local reset_colors="\033[0m"
10 | echo -e "\033[0;31m$message$reset_colors"
11 |
12 | # Set the error flag
13 | ERROR_OCCURRED=1
14 |
15 | # If script is being sourced (. or source)
16 | if [[ "${BASH_SOURCE[0]}" != "${0}" ]] || [[ -n "$ZSH_VERSION" && "$ZSH_EVAL_CONTEXT" == *:file:* ]]; then
17 | echo "Script terminated with error. Returning to shell."
18 | # Reset colors to ensure shell isn't affected
19 | echo -e "$reset_colors"
20 | # The return will be caught by the caller
21 | return 1
22 | else
23 | # If script is being executed directly
24 | exit 1
25 | fi
26 | }
27 |
28 | # Function to check if we should continue after potential error points
29 | should_continue() {
30 | if [ $ERROR_OCCURRED -eq 1 ]; then
31 | # Reset colors to ensure shell isn't affected
32 | echo -e "\033[0m"
33 | return 1
34 | fi
35 | return 0
36 | }
37 |
38 | # Ensure script is running with a compatible shell
39 | if [ -z "$BASH_VERSION" ] && [ -z "$ZSH_VERSION" ]; then
40 | handle_error "This script requires bash or zsh to run. Please run it with: bash $(basename "$0") or zsh $(basename "$0")"
41 | return 1 2>/dev/null || exit 1
42 | fi
43 |
44 | # Set shell options for compatibility
45 | if [ -n "$ZSH_VERSION" ]; then
46 | # ZSH specific settings
47 | setopt SH_WORD_SPLIT
48 | setopt KSH_ARRAYS
49 | fi
50 |
51 | # Colors for better output - ensure they're properly reset after use
52 | GREEN='\033[0;32m'
53 | YELLOW='\033[0;33m'
54 | RED='\033[0;31m'
55 | NC='\033[0m' # No Color
56 |
57 | echo -e "${GREEN}Azure DevOps MCP Server - Environment Setup${NC}"
58 | echo "This script will help you set up your .env file with Azure DevOps credentials."
59 | echo
60 |
61 | # Clean up any existing create_pat.json file
62 | if [ -f "create_pat.json" ]; then
63 | echo -e "${YELLOW}Cleaning up existing create_pat.json file...${NC}"
64 | rm -f create_pat.json
65 | fi
66 |
67 | # Check if Azure CLI is installed
68 | if ! command -v az &> /dev/null; then
69 | handle_error "Error: Azure CLI is not installed.\nPlease install Azure CLI first: https://docs.microsoft.com/en-us/cli/azure/install-azure-cli"
70 | return 1 2>/dev/null || exit 1
71 | fi
72 | should_continue || return 1 2>/dev/null || exit 1
73 |
74 | # Check if Azure DevOps extension is installed
75 | echo -e "${YELLOW}Checking for Azure DevOps extension...${NC}"
76 | az devops &> /dev/null
77 | if [ $? -ne 0 ]; then
78 | echo "Azure DevOps extension not found. Installing..."
79 | az extension add --name azure-devops
80 | if [ $? -ne 0 ]; then
81 | handle_error "Failed to install Azure DevOps extension."
82 | return 1 2>/dev/null || exit 1
83 | else
84 | echo -e "${GREEN}Azure DevOps extension installed successfully.${NC}"
85 | fi
86 | else
87 | echo "Azure DevOps extension is already installed."
88 | fi
89 | should_continue || return 1 2>/dev/null || exit 1
90 |
91 | # Check if jq is installed
92 | if ! command -v jq &> /dev/null; then
93 | handle_error "Error: jq is not installed.\nPlease install jq first. On Ubuntu/Debian: sudo apt-get install jq\nOn macOS: brew install jq"
94 | return 1 2>/dev/null || exit 1
95 | fi
96 | should_continue || return 1 2>/dev/null || exit 1
97 |
98 | # Check if already logged in
99 | echo -e "\n${YELLOW}Step 1: Checking Azure CLI authentication...${NC}"
100 | if ! az account show &> /dev/null; then
101 | echo "Not logged in. Initiating login..."
102 | az login --allow-no-subscriptions
103 | if [ $? -ne 0 ]; then
104 | handle_error "Failed to login to Azure CLI."
105 | return 1 2>/dev/null || exit 1
106 | fi
107 | else
108 | echo -e "${GREEN}Already logged in to Azure CLI.${NC}"
109 | fi
110 | should_continue || return 1 2>/dev/null || exit 1
111 |
112 | # Get Azure DevOps Organizations using REST API
113 | echo -e "\n${YELLOW}Step 2: Fetching your Azure DevOps organizations...${NC}"
114 | echo "This may take a moment..."
115 |
116 | # First get the user profile
117 | echo "Getting user profile..."
118 | profile_response=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/profile/profiles/me?api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798" 2>&1)
119 | profile_status=$?
120 |
121 | if [ $profile_status -ne 0 ]; then
122 | echo -e "${RED}Error: Failed to get user profile${NC}"
123 | echo -e "${RED}Status code: $profile_status${NC}"
124 | echo -e "${RED}Error response:${NC}"
125 | echo "$profile_response"
126 | echo
127 | echo "Manually provide your organization name instead."
128 | read -p "Enter your Azure DevOps organization name: " org_name
129 | else
130 | echo "Profile API response:"
131 | echo "$profile_response"
132 | echo
133 | public_alias=$(echo "$profile_response" | jq -r '.publicAlias')
134 |
135 | if [ "$public_alias" = "null" ] || [ -z "$public_alias" ]; then
136 | echo -e "${RED}Failed to extract publicAlias from response.${NC}"
137 | echo "Full response was:"
138 | echo "$profile_response"
139 | echo
140 | echo "Manually provide your organization name instead."
141 | read -p "Enter your Azure DevOps organization name: " org_name
142 | else
143 | # Get organizations using the publicAlias
144 | echo "Fetching organizations..."
145 | orgs_result=$(az rest --method get --uri "https://app.vssps.visualstudio.com/_apis/accounts?memberId=$public_alias&api-version=6.0" --resource "499b84ac-1321-427f-aa17-267ca6975798")
146 |
147 | # Extract organization names from the response using jq
148 | orgs=$(echo "$orgs_result" | jq -r '.value[].accountName')
149 |
150 | if [ -z "$orgs" ]; then
151 | echo -e "${RED}No organizations found.${NC}"
152 | echo "Manually provide your organization name instead."
153 | read -p "Enter your Azure DevOps organization name: " org_name
154 | else
155 | # Display organizations for selection
156 | echo -e "\nYour Azure DevOps organizations:"
157 | i=1
158 | OLDIFS=$IFS
159 | IFS=$'\n'
160 | # Create array in a shell-agnostic way
161 | orgs_array=()
162 | while IFS= read -r line; do
163 | [ -n "$line" ] && orgs_array+=("$line")
164 | done <<< "$orgs"
165 | IFS=$OLDIFS
166 |
167 | # Check if array is empty
168 | if [ ${#orgs_array[@]} -eq 0 ]; then
169 | echo -e "${RED}Failed to parse organizations list.${NC}"
170 | echo "Manually provide your organization name instead."
171 | read -p "Enter your Azure DevOps organization name: " org_name
172 | else
173 | # Display organizations with explicit indexing
174 | for ((idx=0; idx<${#orgs_array[@]}; idx++)); do
175 | echo "$((idx+1)) ${orgs_array[$idx]}"
176 | done
177 |
178 | # Prompt for selection
179 | read -p "Select an organization (1-${#orgs_array[@]}): " org_selection
180 |
181 | if [[ "$org_selection" =~ ^[0-9]+$ ]] && [ "$org_selection" -ge 1 ] && [ "$org_selection" -le "${#orgs_array[@]}" ]; then
182 | org_name=${orgs_array[$((org_selection-1))]}
183 | else
184 | handle_error "Invalid selection. Please run the script again."
185 | return 1 2>/dev/null || exit 1
186 | fi
187 | fi
188 | fi
189 | fi
190 | fi
191 | should_continue || return 1 2>/dev/null || exit 1
192 |
193 | org_url="https://dev.azure.com/$org_name"
194 | echo -e "${GREEN}Using organization URL: $org_url${NC}"
195 |
196 | # Get Default Project (Optional)
197 | echo -e "\n${YELLOW}Step 3: Would you like to set a default project? (y/n)${NC}"
198 | read -p "Select option: " set_default_project
199 |
200 | default_project=""
201 | if [[ "$set_default_project" = "y" || "$set_default_project" = "Y" ]]; then
202 | # Configure az devops to use the selected organization
203 | az devops configure --defaults organization=$org_url
204 |
205 | # List projects
206 | echo "Fetching projects from $org_name..."
207 | projects=$(az devops project list --query "value[].name" -o tsv)
208 |
209 | if [ $? -ne 0 ] || [ -z "$projects" ]; then
210 | echo -e "${YELLOW}No projects found or unable to list projects.${NC}"
211 | read -p "Enter a default project name (leave blank to skip): " default_project
212 | else
213 | # Display projects for selection
214 | echo -e "\nAvailable projects in $org_name:"
215 | OLDIFS=$IFS
216 | IFS=$'\n'
217 | # Create array in a shell-agnostic way
218 | projects_array=()
219 | while IFS= read -r line; do
220 | [ -n "$line" ] && projects_array+=("$line")
221 | done <<< "$projects"
222 | IFS=$OLDIFS
223 |
224 | # Check if array is empty
225 | if [ ${#projects_array[@]} -eq 0 ]; then
226 | echo -e "${YELLOW}Failed to parse projects list.${NC}"
227 | read -p "Enter a default project name (leave blank to skip): " default_project
228 | else
229 | # Display projects with explicit indexing
230 | for ((idx=0; idx<${#projects_array[@]}; idx++)); do
231 | echo "$((idx+1)) ${projects_array[$idx]}"
232 | done
233 |
234 | echo "$((${#projects_array[@]}+1)) Skip setting a default project"
235 |
236 | # Prompt for selection
237 | read -p "Select a default project (1-$((${#projects_array[@]}+1))): " project_selection
238 |
239 | if [[ "$project_selection" =~ ^[0-9]+$ ]] && [ "$project_selection" -ge 1 ] && [ "$project_selection" -lt "$((${#projects_array[@]}+1))" ]; then
240 | default_project=${projects_array[$((project_selection-1))]}
241 | echo -e "${GREEN}Using default project: $default_project${NC}"
242 | else
243 | echo "No default project selected."
244 | fi
245 | fi
246 | fi
247 | fi
248 |
249 | # Create .env file
250 | echo -e "\n${YELLOW}Step 5: Creating .env file...${NC}"
251 |
252 | cat > .env << EOF
253 | # Azure DevOps MCP Server - Environment Variables
254 |
255 | # Azure DevOps Organization Name (selected from your available organizations)
256 | AZURE_DEVOPS_ORG=$org_name
257 |
258 | # Azure DevOps Organization URL (required)
259 | AZURE_DEVOPS_ORG_URL=$org_url
260 |
261 |
262 | AZURE_DEVOPS_AUTH_METHOD=azure-identity
263 | EOF
264 |
265 | # Add default project if specified
266 | if [ ! -z "$default_project" ]; then
267 | cat >> .env << EOF
268 |
269 | # Default Project to use when not specified
270 | AZURE_DEVOPS_DEFAULT_PROJECT=$default_project
271 | EOF
272 | else
273 | cat >> .env << EOF
274 |
275 | # Default Project to use when not specified (optional)
276 | # AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project
277 | EOF
278 | fi
279 |
280 | # Add remaining configuration
281 | cat >> .env << EOF
282 |
283 | # API Version to use (optional, defaults to latest)
284 | # AZURE_DEVOPS_API_VERSION=6.0
285 |
286 | # Server Configuration
287 | PORT=3000
288 | HOST=localhost
289 |
290 | # Logging Level (debug, info, warn, error)
291 | LOG_LEVEL=info
292 | EOF
293 |
294 | echo -e "\n${GREEN}Environment setup completed successfully!${NC}"
295 | echo "Your .env file has been created with the following configuration:"
296 | echo "- Organization: $org_name"
297 | echo "- Organization URL: $org_url"
298 | if [ ! -z "$default_project" ]; then
299 | echo "- Default Project: $default_project"
300 | fi
301 | echo "- PAT: Created with expanded scopes for full integration"
302 | echo
303 | echo "You can now run your Azure DevOps MCP Server with:"
304 | echo " npm run dev"
305 | echo
306 | echo "You can also run integration tests with:"
307 | echo " npm run test:integration"
308 |
309 | # At the end of the script, ensure colors are reset
310 | echo -e "${NC}"
```
--------------------------------------------------------------------------------
/src/features/repositories/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
3 | import { isRepositoriesRequest, handleRepositoriesRequest } from './index';
4 | import { getRepository } from './get-repository';
5 | import { getRepositoryDetails } from './get-repository-details';
6 | import { listRepositories } from './list-repositories';
7 | import { getFileContent } from './get-file-content';
8 | import {
9 | getAllRepositoriesTree,
10 | formatRepositoryTree,
11 | } from './get-all-repositories-tree';
12 | import { getRepositoryTree } from './get-repository-tree';
13 | import { createBranch } from './create-branch';
14 | import { createCommit } from './create-commit';
15 | import { listCommits } from './list-commits';
16 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces';
17 |
18 | // Mock the imported modules
19 | jest.mock('./get-repository', () => ({
20 | getRepository: jest.fn(),
21 | }));
22 |
23 | jest.mock('./get-repository-details', () => ({
24 | getRepositoryDetails: jest.fn(),
25 | }));
26 |
27 | jest.mock('./list-repositories', () => ({
28 | listRepositories: jest.fn(),
29 | }));
30 |
31 | jest.mock('./get-file-content', () => ({
32 | getFileContent: jest.fn(),
33 | }));
34 |
35 | jest.mock('./get-all-repositories-tree', () => ({
36 | getAllRepositoriesTree: jest.fn(),
37 | formatRepositoryTree: jest.fn(),
38 | }));
39 |
40 | jest.mock('./get-repository-tree', () => ({
41 | getRepositoryTree: jest.fn(),
42 | }));
43 |
44 | jest.mock('./create-branch', () => ({
45 | createBranch: jest.fn(),
46 | }));
47 |
48 | jest.mock('./create-commit', () => ({
49 | createCommit: jest.fn(),
50 | }));
51 |
52 | jest.mock('./list-commits', () => ({
53 | listCommits: jest.fn(),
54 | }));
55 |
56 | describe('Repositories Request Handlers', () => {
57 | const mockConnection = {} as WebApi;
58 |
59 | describe('isRepositoriesRequest', () => {
60 | it('should return true for repositories requests', () => {
61 | const validTools = [
62 | 'get_repository',
63 | 'get_repository_details',
64 | 'list_repositories',
65 | 'get_file_content',
66 | 'get_all_repositories_tree',
67 | 'get_repository_tree',
68 | 'create_branch',
69 | 'create_commit',
70 | 'list_commits',
71 | ];
72 | validTools.forEach((tool) => {
73 | const request = {
74 | params: { name: tool, arguments: {} },
75 | method: 'tools/call',
76 | } as CallToolRequest;
77 | expect(isRepositoriesRequest(request)).toBe(true);
78 | });
79 | });
80 |
81 | it('should return false for non-repositories requests', () => {
82 | const request = {
83 | params: { name: 'list_projects', arguments: {} },
84 | method: 'tools/call',
85 | } as CallToolRequest;
86 | expect(isRepositoriesRequest(request)).toBe(false);
87 | });
88 | });
89 |
90 | describe('handleRepositoriesRequest', () => {
91 | it('should handle get_repository request', async () => {
92 | const mockRepository = { id: 'repo1', name: 'Repository 1' };
93 | (getRepository as jest.Mock).mockResolvedValue(mockRepository);
94 |
95 | const request = {
96 | params: {
97 | name: 'get_repository',
98 | arguments: {
99 | repositoryId: 'repo1',
100 | },
101 | },
102 | method: 'tools/call',
103 | } as CallToolRequest;
104 |
105 | const response = await handleRepositoriesRequest(mockConnection, request);
106 | expect(response.content).toHaveLength(1);
107 | expect(JSON.parse(response.content[0].text as string)).toEqual(
108 | mockRepository,
109 | );
110 | expect(getRepository).toHaveBeenCalledWith(
111 | mockConnection,
112 | expect.any(String),
113 | 'repo1',
114 | );
115 | });
116 |
117 | it('should handle get_repository_details request', async () => {
118 | const mockRepositoryDetails = {
119 | repository: { id: 'repo1', name: 'Repository 1' },
120 | statistics: { branches: [] },
121 | refs: { value: [], count: 0 },
122 | };
123 | (getRepositoryDetails as jest.Mock).mockResolvedValue(
124 | mockRepositoryDetails,
125 | );
126 |
127 | const request = {
128 | params: {
129 | name: 'get_repository_details',
130 | arguments: {
131 | repositoryId: 'repo1',
132 | includeStatistics: true,
133 | includeRefs: true,
134 | },
135 | },
136 | method: 'tools/call',
137 | } as CallToolRequest;
138 |
139 | const response = await handleRepositoriesRequest(mockConnection, request);
140 | expect(response.content).toHaveLength(1);
141 | expect(JSON.parse(response.content[0].text as string)).toEqual(
142 | mockRepositoryDetails,
143 | );
144 | expect(getRepositoryDetails).toHaveBeenCalledWith(
145 | mockConnection,
146 | expect.objectContaining({
147 | repositoryId: 'repo1',
148 | includeStatistics: true,
149 | includeRefs: true,
150 | }),
151 | );
152 | });
153 |
154 | it('should handle list_repositories request', async () => {
155 | const mockRepositories = [
156 | { id: 'repo1', name: 'Repository 1' },
157 | { id: 'repo2', name: 'Repository 2' },
158 | ];
159 | (listRepositories as jest.Mock).mockResolvedValue(mockRepositories);
160 |
161 | const request = {
162 | params: {
163 | name: 'list_repositories',
164 | arguments: {
165 | projectId: 'project1',
166 | includeLinks: true,
167 | },
168 | },
169 | method: 'tools/call',
170 | } as CallToolRequest;
171 |
172 | const response = await handleRepositoriesRequest(mockConnection, request);
173 | expect(response.content).toHaveLength(1);
174 | expect(JSON.parse(response.content[0].text as string)).toEqual(
175 | mockRepositories,
176 | );
177 | expect(listRepositories).toHaveBeenCalledWith(
178 | mockConnection,
179 | expect.objectContaining({
180 | projectId: 'project1',
181 | includeLinks: true,
182 | }),
183 | );
184 | });
185 |
186 | it('should handle get_file_content request', async () => {
187 | const mockFileContent = { content: 'file content', isFolder: false };
188 | (getFileContent as jest.Mock).mockResolvedValue(mockFileContent);
189 |
190 | const request = {
191 | params: {
192 | name: 'get_file_content',
193 | arguments: {
194 | repositoryId: 'repo1',
195 | path: '/path/to/file',
196 | version: 'main',
197 | versionType: 'branch',
198 | },
199 | },
200 | method: 'tools/call',
201 | } as CallToolRequest;
202 |
203 | const response = await handleRepositoriesRequest(mockConnection, request);
204 | expect(response.content).toHaveLength(1);
205 | expect(JSON.parse(response.content[0].text as string)).toEqual(
206 | mockFileContent,
207 | );
208 | expect(getFileContent).toHaveBeenCalledWith(
209 | mockConnection,
210 | expect.any(String),
211 | 'repo1',
212 | '/path/to/file',
213 | { versionType: GitVersionType.Branch, version: 'main' },
214 | );
215 | });
216 |
217 | it('should handle get_all_repositories_tree request', async () => {
218 | const mockTreeResponse = {
219 | repositories: [
220 | {
221 | name: 'repo1',
222 | tree: [
223 | { name: 'file1', path: '/file1', isFolder: false, level: 0 },
224 | ],
225 | stats: { directories: 0, files: 1 },
226 | },
227 | ],
228 | };
229 | (getAllRepositoriesTree as jest.Mock).mockResolvedValue(mockTreeResponse);
230 | (formatRepositoryTree as jest.Mock).mockReturnValue('repo1\n file1\n');
231 |
232 | const request = {
233 | params: {
234 | name: 'get_all_repositories_tree',
235 | arguments: {
236 | projectId: 'project1',
237 | depth: 2,
238 | },
239 | },
240 | method: 'tools/call',
241 | } as CallToolRequest;
242 |
243 | const response = await handleRepositoriesRequest(mockConnection, request);
244 | expect(response.content).toHaveLength(1);
245 | expect(response.content[0].text as string).toContain('repo1');
246 | expect(getAllRepositoriesTree).toHaveBeenCalledWith(
247 | mockConnection,
248 | expect.objectContaining({
249 | projectId: 'project1',
250 | depth: 2,
251 | }),
252 | );
253 | expect(formatRepositoryTree).toHaveBeenCalledWith(
254 | 'repo1',
255 | expect.any(Array),
256 | expect.any(Object),
257 | undefined,
258 | );
259 | });
260 |
261 | it('should handle get_repository_tree request', async () => {
262 | const mockResponse = {
263 | name: 'repo',
264 | tree: [],
265 | stats: { directories: 0, files: 0 },
266 | };
267 | (getRepositoryTree as jest.Mock).mockResolvedValue(mockResponse);
268 |
269 | const request = {
270 | params: {
271 | name: 'get_repository_tree',
272 | arguments: { repositoryId: 'r' },
273 | },
274 | method: 'tools/call',
275 | } as CallToolRequest;
276 |
277 | const response = await handleRepositoriesRequest(mockConnection, request);
278 | expect(JSON.parse(response.content[0].text as string)).toEqual(
279 | mockResponse,
280 | );
281 | expect(getRepositoryTree).toHaveBeenCalled();
282 | });
283 |
284 | it('should handle create_branch request', async () => {
285 | const request = {
286 | params: {
287 | name: 'create_branch',
288 | arguments: {
289 | repositoryId: 'r',
290 | sourceBranch: 'main',
291 | newBranch: 'feature',
292 | },
293 | },
294 | method: 'tools/call',
295 | } as CallToolRequest;
296 |
297 | const response = await handleRepositoriesRequest(mockConnection, request);
298 | expect(response.content[0].text).toContain('Branch created');
299 | expect(createBranch).toHaveBeenCalled();
300 | });
301 |
302 | it('should handle create_commit request', async () => {
303 | const request = {
304 | params: {
305 | name: 'create_commit',
306 | arguments: {
307 | repositoryId: 'r',
308 | branchName: 'main',
309 | commitMessage: 'msg',
310 | changes: [],
311 | },
312 | },
313 | method: 'tools/call',
314 | } as CallToolRequest;
315 |
316 | const response = await handleRepositoriesRequest(mockConnection, request);
317 | expect(response.content[0].text).toContain('Commit created');
318 | expect(createCommit).toHaveBeenCalled();
319 | });
320 |
321 | it('should handle list_commits request', async () => {
322 | (listCommits as jest.Mock).mockResolvedValue({ commits: [] });
323 |
324 | const request = {
325 | params: {
326 | name: 'list_commits',
327 | arguments: {
328 | repositoryId: 'r',
329 | branchName: 'main',
330 | },
331 | },
332 | method: 'tools/call',
333 | } as CallToolRequest;
334 |
335 | const response = await handleRepositoriesRequest(mockConnection, request);
336 | expect(JSON.parse(response.content[0].text as string)).toEqual({
337 | commits: [],
338 | });
339 | expect(listCommits).toHaveBeenCalled();
340 | });
341 |
342 | it('should throw error for unknown tool', async () => {
343 | const request = {
344 | params: {
345 | name: 'unknown_tool',
346 | arguments: {},
347 | },
348 | method: 'tools/call',
349 | } as CallToolRequest;
350 |
351 | await expect(
352 | handleRepositoriesRequest(mockConnection, request),
353 | ).rejects.toThrow('Unknown repositories tool');
354 | });
355 |
356 | it('should propagate errors from repository functions', async () => {
357 | const mockError = new Error('Test error');
358 | (listRepositories as jest.Mock).mockRejectedValue(mockError);
359 |
360 | const request = {
361 | params: {
362 | name: 'list_repositories',
363 | arguments: {
364 | projectId: 'project1',
365 | },
366 | },
367 | method: 'tools/call',
368 | } as CallToolRequest;
369 |
370 | await expect(
371 | handleRepositoriesRequest(mockConnection, request),
372 | ).rejects.toThrow(mockError);
373 | });
374 | });
375 | });
376 |
```
--------------------------------------------------------------------------------
/.github/skills/skill-creator/LICENSE.txt:
--------------------------------------------------------------------------------
```
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
```
--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-checks/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | GitPullRequestStatus,
4 | GitStatusState,
5 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
6 | import {
7 | PolicyEvaluationRecord,
8 | PolicyEvaluationStatus,
9 | } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
10 | import {
11 | AzureDevOpsError,
12 | AzureDevOpsResourceNotFoundError,
13 | } from '../../../shared/errors';
14 |
15 | export interface PullRequestChecksOptions {
16 | projectId: string;
17 | repositoryId: string;
18 | pullRequestId: number;
19 | }
20 |
21 | export interface PipelineReference {
22 | pipelineId?: number;
23 | definitionId?: number;
24 | runId?: number;
25 | buildId?: number;
26 | displayName?: string;
27 | targetUrl?: string;
28 | }
29 |
30 | export interface PullRequestStatusCheck {
31 | id?: number;
32 | state: string;
33 | description?: string;
34 | context?: {
35 | name?: string;
36 | genre?: string;
37 | };
38 | createdDate?: string;
39 | updatedDate?: string;
40 | targetUrl?: string;
41 | pipeline?: PipelineReference;
42 | }
43 |
44 | export interface PullRequestPolicyCheck {
45 | evaluationId?: string;
46 | status: string;
47 | isBlocking?: boolean;
48 | isEnabled?: boolean;
49 | configurationId?: number;
50 | configurationRevision?: number;
51 | configurationTypeId?: string;
52 | configurationTypeDisplayName?: string;
53 | displayName?: string;
54 | startedDate?: string;
55 | completedDate?: string;
56 | message?: string;
57 | targetUrl?: string;
58 | pipeline?: PipelineReference;
59 | }
60 |
61 | export interface PullRequestChecksResult {
62 | statuses: PullRequestStatusCheck[];
63 | policyEvaluations: PullRequestPolicyCheck[];
64 | }
65 |
66 | /**
67 | * Retrieve status checks and policy evaluations for a pull request.
68 | */
69 | export async function getPullRequestChecks(
70 | connection: WebApi,
71 | options: PullRequestChecksOptions,
72 | ): Promise<PullRequestChecksResult> {
73 | try {
74 | const [gitApi, policyApi, projectId] = await Promise.all([
75 | connection.getGitApi(),
76 | connection.getPolicyApi(),
77 | resolveProjectId(connection, options.projectId),
78 | ]);
79 |
80 | const [statusRecords, evaluationRecords] = await Promise.all([
81 | gitApi.getPullRequestStatuses(
82 | options.repositoryId,
83 | options.pullRequestId,
84 | projectId,
85 | ),
86 | policyApi.getPolicyEvaluations(
87 | projectId,
88 | buildPolicyArtifactId(projectId, options.pullRequestId),
89 | ),
90 | ]);
91 |
92 | return {
93 | statuses: (statusRecords ?? []).map(mapStatusRecord),
94 | policyEvaluations: (evaluationRecords ?? []).map(mapEvaluationRecord),
95 | };
96 | } catch (error) {
97 | if (error instanceof AzureDevOpsError) {
98 | throw error;
99 | }
100 | throw new Error(
101 | `Failed to get pull request checks: ${
102 | error instanceof Error ? error.message : String(error)
103 | }`,
104 | );
105 | }
106 | }
107 |
108 | const buildPolicyArtifactId = (projectId: string, pullRequestId: number) =>
109 | `vstfs:///CodeReview/CodeReviewId/${projectId}/${pullRequestId}`;
110 |
111 | const projectIdGuidPattern =
112 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
113 |
114 | const resolveProjectId = async (
115 | connection: WebApi,
116 | projectIdOrName: string,
117 | ): Promise<string> => {
118 | if (projectIdGuidPattern.test(projectIdOrName)) {
119 | return projectIdOrName;
120 | }
121 |
122 | const coreApi = await connection.getCoreApi();
123 | const project = await coreApi.getProject(projectIdOrName);
124 |
125 | if (!project?.id) {
126 | throw new AzureDevOpsResourceNotFoundError(
127 | `Project '${projectIdOrName}' not found`,
128 | );
129 | }
130 |
131 | return project.id;
132 | };
133 |
134 | const gitStatusStateMap = GitStatusState as unknown as Record<number, string>;
135 | const policyStatusMap = PolicyEvaluationStatus as unknown as Record<
136 | number,
137 | string
138 | >;
139 |
140 | const mapStatusRecord = (
141 | status: GitPullRequestStatus,
142 | ): PullRequestStatusCheck => {
143 | const pipeline = mergePipelineReferences(
144 | parsePipelineReferenceFromUrl(status.targetUrl),
145 | extractPipelineReferenceFromObject(status.context),
146 | extractPipelineReferenceFromObject(status.properties),
147 | );
148 |
149 | return {
150 | id: status.id,
151 | state: formatEnumValue(status.state, gitStatusStateMap),
152 | description: status.description,
153 | context: {
154 | name: status.context?.name,
155 | genre: status.context?.genre,
156 | },
157 | createdDate: toIsoString(status.creationDate),
158 | updatedDate: toIsoString(status.updatedDate),
159 | targetUrl: status.targetUrl ?? pipeline?.targetUrl,
160 | pipeline,
161 | };
162 | };
163 |
164 | const mapEvaluationRecord = (
165 | evaluation: PolicyEvaluationRecord,
166 | ): PullRequestPolicyCheck => {
167 | const settings =
168 | (evaluation.configuration?.settings as Record<string, unknown>) || {};
169 | const context =
170 | (evaluation.context as Record<string, unknown> | undefined) ?? {};
171 |
172 | const pipeline = mergePipelineReferences(
173 | extractPipelineReferenceFromObject(settings),
174 | extractPipelineReferenceFromObject(context),
175 | parsePipelineReferenceFromUrl(
176 | extractString(settings.targetUrl) ?? extractString(context.targetUrl),
177 | ),
178 | );
179 |
180 | const displayName =
181 | extractString(settings.displayName) ??
182 | extractString(context.displayName) ??
183 | evaluation.configuration?.type?.displayName;
184 |
185 | const targetUrl =
186 | pipeline?.targetUrl ??
187 | extractString(context.targetUrl) ??
188 | extractString(settings.targetUrl);
189 |
190 | return {
191 | evaluationId: evaluation.evaluationId,
192 | status: formatEnumValue(evaluation.status, policyStatusMap),
193 | isBlocking: evaluation.configuration?.isBlocking,
194 | isEnabled: evaluation.configuration?.isEnabled,
195 | configurationId: evaluation.configuration?.id,
196 | configurationRevision: evaluation.configuration?.revision,
197 | configurationTypeId: evaluation.configuration?.type?.id,
198 | configurationTypeDisplayName: evaluation.configuration?.type?.displayName,
199 | displayName,
200 | startedDate: toIsoString(evaluation.startedDate),
201 | completedDate: toIsoString(evaluation.completedDate),
202 | message: extractString(context.message) ?? extractString(settings.message),
203 | targetUrl,
204 | pipeline,
205 | };
206 | };
207 |
208 | const formatEnumValue = (
209 | value: number | undefined,
210 | map: Record<number, string>,
211 | ): string => {
212 | if (typeof value === 'number' && map[value]) {
213 | const name = map[value];
214 | return name.charAt(0).toLowerCase() + name.slice(1);
215 | }
216 | return 'unknown';
217 | };
218 |
219 | const toIsoString = (date: Date | undefined): string | undefined =>
220 | date ? date.toISOString() : undefined;
221 |
222 | const extractString = (value: unknown): string | undefined =>
223 | typeof value === 'string' ? value : undefined;
224 |
225 | const parseNumeric = (value: unknown): number | undefined => {
226 | if (value === null || value === undefined) {
227 | return undefined;
228 | }
229 | const numeric = Number(value);
230 | return Number.isFinite(numeric) ? numeric : undefined;
231 | };
232 |
233 | const parseIdFromUri = (uri?: string): number | undefined => {
234 | if (!uri) {
235 | return undefined;
236 | }
237 | const match = uri.match(/(\d+)(?!.*\d)/);
238 | if (!match) {
239 | return undefined;
240 | }
241 | const id = Number(match[1]);
242 | return Number.isFinite(id) ? id : undefined;
243 | };
244 |
245 | const parsePipelineReferenceFromUrl = (
246 | targetUrl?: string,
247 | ): PipelineReference | undefined => {
248 | if (!targetUrl) {
249 | return undefined;
250 | }
251 |
252 | try {
253 | const url = new URL(targetUrl);
254 | const result: PipelineReference = { targetUrl };
255 |
256 | const setParam = (param: string, setter: (value: number) => void) => {
257 | const raw = url.searchParams.get(param);
258 | const numeric = parseNumeric(raw);
259 | if (numeric !== undefined) {
260 | setter(numeric);
261 | }
262 | };
263 |
264 | setParam('pipelineId', (value) => {
265 | result.pipelineId = value;
266 | });
267 | setParam('definitionId', (value) => {
268 | result.definitionId = value;
269 | });
270 | setParam('buildDefinitionId', (value) => {
271 | result.definitionId = result.definitionId ?? value;
272 | });
273 | setParam('runId', (value) => {
274 | result.runId = value;
275 | });
276 | setParam('buildId', (value) => {
277 | result.buildId = value;
278 | result.runId = result.runId ?? value;
279 | });
280 |
281 | const segments = url.pathname.split('/').filter(Boolean);
282 |
283 | const pipelinesIndex = segments.lastIndexOf('pipelines');
284 | if (pipelinesIndex !== -1 && pipelinesIndex + 1 < segments.length) {
285 | const pipelineCandidate = parseNumeric(segments[pipelinesIndex + 1]);
286 | if (pipelineCandidate !== undefined) {
287 | result.pipelineId = result.pipelineId ?? pipelineCandidate;
288 | }
289 | }
290 |
291 | const runsIndex = segments.lastIndexOf('runs');
292 | if (runsIndex !== -1 && runsIndex + 1 < segments.length) {
293 | const runCandidate = parseNumeric(segments[runsIndex + 1]);
294 | if (runCandidate !== undefined) {
295 | result.runId = result.runId ?? runCandidate;
296 | }
297 |
298 | if (runsIndex > 0) {
299 | const preceding = segments[runsIndex - 1];
300 | const pipelineCandidate = parseNumeric(preceding);
301 | if (pipelineCandidate !== undefined) {
302 | result.pipelineId = result.pipelineId ?? pipelineCandidate;
303 | }
304 | }
305 | }
306 |
307 | const buildMatch = url.pathname.match(/\/build\/(?:definition\/)?(\d+)/i);
308 | if (!result.definitionId && buildMatch) {
309 | const id = parseNumeric(buildMatch[1]);
310 | if (id !== undefined) {
311 | result.definitionId = id;
312 | }
313 | }
314 |
315 | const buildUriMatch = url.pathname.match(/\/Build\/Build\/(\d+)/i);
316 | if (buildUriMatch) {
317 | const buildId = parseNumeric(buildUriMatch[1]);
318 | if (buildId !== undefined) {
319 | result.buildId = result.buildId ?? buildId;
320 | result.runId = result.runId ?? buildId;
321 | }
322 | }
323 |
324 | return result;
325 | } catch {
326 | return { targetUrl };
327 | }
328 | };
329 |
330 | const extractPipelineReferenceFromObject = (
331 | value: unknown,
332 | ): PipelineReference | undefined => {
333 | if (!value || typeof value !== 'object') {
334 | return undefined;
335 | }
336 |
337 | const object = value as Record<string, unknown>;
338 | const candidate: PipelineReference = {};
339 |
340 | const pipelineId = parseNumeric(
341 | object.pipelineId ??
342 | (object.pipeline as Record<string, unknown> | undefined)?.id,
343 | );
344 | if (pipelineId !== undefined) {
345 | candidate.pipelineId = pipelineId;
346 | }
347 |
348 | const definitionId = parseNumeric(
349 | object.definitionId ??
350 | object.buildDefinitionId ??
351 | object.pipelineDefinitionId ??
352 | (object.definition as Record<string, unknown> | undefined)?.id,
353 | );
354 | if (definitionId !== undefined) {
355 | candidate.definitionId = definitionId;
356 | }
357 |
358 | const runId = parseNumeric(
359 | object.runId ??
360 | object.buildId ??
361 | object.stageRunId ??
362 | object.jobRunId ??
363 | object.planId,
364 | );
365 | if (runId !== undefined) {
366 | candidate.runId = runId;
367 | }
368 |
369 | const buildId = parseNumeric(
370 | object.buildId ??
371 | (object.build as Record<string, unknown> | undefined)?.id ??
372 | parseIdFromUri(extractString(object.buildUri)) ??
373 | parseIdFromUri(extractString(object.uri)),
374 | );
375 | if (buildId !== undefined) {
376 | candidate.buildId = candidate.buildId ?? buildId;
377 | if (candidate.runId === undefined) {
378 | candidate.runId = buildId;
379 | }
380 | }
381 |
382 | const displayName =
383 | extractString(object.displayName) ?? extractString(object.name);
384 | if (displayName) {
385 | candidate.displayName = displayName;
386 | }
387 |
388 | const targetUrl =
389 | extractString(object.targetUrl) ??
390 | extractString(object.url) ??
391 | extractString(object.href);
392 |
393 | return mergePipelineReferences(
394 | candidate,
395 | parsePipelineReferenceFromUrl(targetUrl),
396 | );
397 | };
398 |
399 | const mergePipelineReferences = (
400 | ...refs: Array<PipelineReference | undefined>
401 | ): PipelineReference | undefined => {
402 | const merged: PipelineReference = {};
403 | let hasValue = false;
404 |
405 | for (const ref of refs) {
406 | if (!ref) {
407 | continue;
408 | }
409 | const apply = <K extends keyof PipelineReference>(key: K) => {
410 | const value = ref[key];
411 | if (value !== undefined && merged[key] === undefined) {
412 | merged[key] = value;
413 | hasValue = true;
414 | }
415 | };
416 |
417 | apply('pipelineId');
418 | apply('definitionId');
419 | apply('runId');
420 | apply('buildId');
421 | apply('displayName');
422 | apply('targetUrl');
423 | }
424 |
425 | return hasValue ? merged : undefined;
426 | };
427 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/update-pull-request/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces';
2 | import { WebApi } from 'azure-devops-node-api';
3 | import {
4 | WorkItemRelation,
5 | WorkItemExpand,
6 | } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
7 | import { AzureDevOpsClient } from '../../../shared/auth/client-factory';
8 | import { AzureDevOpsError } from '../../../shared/errors';
9 | import { UpdatePullRequestOptions } from '../types';
10 | import { AuthenticationMethod } from '../../../shared/auth/auth-factory';
11 | import { pullRequestStatusMapper } from '../../../shared/enums';
12 |
13 | function normalizeTags(tags?: string[]): string[] {
14 | if (!tags) {
15 | return [];
16 | }
17 |
18 | const seen = new Set<string>();
19 | const normalized: string[] = [];
20 |
21 | for (const rawTag of tags) {
22 | const trimmed = rawTag.trim();
23 | if (!trimmed) {
24 | continue;
25 | }
26 |
27 | const key = trimmed.toLowerCase();
28 | if (seen.has(key)) {
29 | continue;
30 | }
31 |
32 | seen.add(key);
33 | normalized.push(trimmed);
34 | }
35 |
36 | return normalized;
37 | }
38 |
39 | /**
40 | * Updates an existing pull request in Azure DevOps with the specified changes.
41 | *
42 | * @param options - The options for updating the pull request
43 | * @returns The updated pull request
44 | */
45 | export const updatePullRequest = async (
46 | options: UpdatePullRequestOptions,
47 | ): Promise<GitPullRequest> => {
48 | const {
49 | projectId,
50 | repositoryId,
51 | pullRequestId,
52 | title,
53 | description,
54 | status,
55 | isDraft,
56 | addWorkItemIds,
57 | removeWorkItemIds,
58 | addReviewers,
59 | removeReviewers,
60 | addTags,
61 | removeTags,
62 | additionalProperties,
63 | } = options;
64 |
65 | try {
66 | // Get connection to Azure DevOps
67 | const client = new AzureDevOpsClient({
68 | method:
69 | (process.env.AZURE_DEVOPS_AUTH_METHOD as AuthenticationMethod) ?? 'pat',
70 | organizationUrl: process.env.AZURE_DEVOPS_ORG_URL ?? '',
71 | personalAccessToken: process.env.AZURE_DEVOPS_PAT,
72 | });
73 | const connection = await client.getWebApiClient();
74 |
75 | // Get the Git API client
76 | const gitApi = await connection.getGitApi();
77 |
78 | // First, get the current pull request
79 | const pullRequest = await gitApi.getPullRequestById(
80 | pullRequestId,
81 | projectId,
82 | );
83 |
84 | if (!pullRequest) {
85 | throw new AzureDevOpsError(
86 | `Pull request ${pullRequestId} not found in repository ${repositoryId}`,
87 | );
88 | }
89 |
90 | // Store the artifactId for work item linking
91 | const artifactId = pullRequest.artifactId;
92 | const effectivePullRequestId = pullRequest.pullRequestId ?? pullRequestId;
93 |
94 | // Create an object with the properties to update
95 | const updateObject: Partial<GitPullRequest> = {};
96 |
97 | if (title !== undefined) {
98 | updateObject.title = title;
99 | }
100 |
101 | if (description !== undefined) {
102 | updateObject.description = description;
103 | }
104 |
105 | if (isDraft !== undefined) {
106 | updateObject.isDraft = isDraft;
107 | }
108 |
109 | if (status) {
110 | const enumStatus = pullRequestStatusMapper.toEnum(status);
111 | if (enumStatus !== undefined) {
112 | updateObject.status = enumStatus;
113 | } else {
114 | throw new AzureDevOpsError(
115 | `Invalid status: ${status}. Valid values are: active, abandoned, completed`,
116 | );
117 | }
118 | }
119 |
120 | // Add any additional properties that were specified
121 | if (additionalProperties) {
122 | Object.assign(updateObject, additionalProperties);
123 | }
124 |
125 | // Update the pull request
126 | const updatedPullRequest = await gitApi.updatePullRequest(
127 | updateObject,
128 | repositoryId,
129 | pullRequestId,
130 | projectId,
131 | );
132 |
133 | // Handle work items separately if needed
134 | const addIds = addWorkItemIds ?? [];
135 | const removeIds = removeWorkItemIds ?? [];
136 | if (addIds.length > 0 || removeIds.length > 0) {
137 | await handleWorkItems({
138 | connection,
139 | pullRequestId,
140 | repositoryId,
141 | projectId,
142 | workItemIdsToAdd: addIds,
143 | workItemIdsToRemove: removeIds,
144 | artifactId,
145 | });
146 | }
147 |
148 | // Handle reviewers separately if needed
149 | const addReviewerIds = addReviewers ?? [];
150 | const removeReviewerIds = removeReviewers ?? [];
151 | if (addReviewerIds.length > 0 || removeReviewerIds.length > 0) {
152 | await handleReviewers({
153 | connection,
154 | pullRequestId,
155 | repositoryId,
156 | projectId,
157 | reviewersToAdd: addReviewerIds,
158 | reviewersToRemove: removeReviewerIds,
159 | });
160 | }
161 |
162 | const normalizedTagsToAdd = normalizeTags(addTags);
163 | const normalizedTagsToRemove = normalizeTags(removeTags);
164 |
165 | if (
166 | effectivePullRequestId &&
167 | (normalizedTagsToAdd.length > 0 || normalizedTagsToRemove.length > 0)
168 | ) {
169 | let labels =
170 | (await gitApi.getPullRequestLabels(
171 | repositoryId,
172 | effectivePullRequestId,
173 | projectId,
174 | )) ?? [];
175 |
176 | const existingNames = new Set(
177 | labels
178 | .map((label) => label.name?.toLowerCase())
179 | .filter((name): name is string => Boolean(name)),
180 | );
181 |
182 | const tagsToCreate = normalizedTagsToAdd.filter(
183 | (tag) => !existingNames.has(tag.toLowerCase()),
184 | );
185 |
186 | for (const tag of tagsToCreate) {
187 | try {
188 | const createdLabel = await gitApi.createPullRequestLabel(
189 | { name: tag },
190 | repositoryId,
191 | effectivePullRequestId,
192 | projectId,
193 | );
194 | labels.push(createdLabel);
195 | existingNames.add(tag.toLowerCase());
196 | } catch (error) {
197 | throw new Error(
198 | `Failed to add tag '${tag}': ${
199 | error instanceof Error ? error.message : String(error)
200 | }`,
201 | );
202 | }
203 | }
204 |
205 | for (const tag of normalizedTagsToRemove) {
206 | try {
207 | await gitApi.deletePullRequestLabels(
208 | repositoryId,
209 | effectivePullRequestId,
210 | tag,
211 | projectId,
212 | );
213 | labels = labels.filter((label) => {
214 | const name = label.name?.toLowerCase();
215 | return name ? name !== tag.toLowerCase() : true;
216 | });
217 | existingNames.delete(tag.toLowerCase());
218 | } catch (error) {
219 | if (
220 | error &&
221 | typeof error === 'object' &&
222 | 'statusCode' in error &&
223 | (error as { statusCode?: number }).statusCode === 404
224 | ) {
225 | continue;
226 | }
227 |
228 | throw new Error(
229 | `Failed to remove tag '${tag}': ${
230 | error instanceof Error ? error.message : String(error)
231 | }`,
232 | );
233 | }
234 | }
235 |
236 | updatedPullRequest.labels = labels;
237 | }
238 |
239 | return updatedPullRequest;
240 | } catch (error) {
241 | throw new AzureDevOpsError(
242 | `Failed to update pull request ${pullRequestId} in repository ${repositoryId}: ${error instanceof Error ? error.message : String(error)}`,
243 | );
244 | }
245 | };
246 |
247 | /**
248 | * Handle adding or removing work items from a pull request
249 | */
250 | interface WorkItemHandlingOptions {
251 | connection: WebApi;
252 | pullRequestId: number;
253 | repositoryId: string;
254 | projectId?: string;
255 | workItemIdsToAdd: number[];
256 | workItemIdsToRemove: number[];
257 | artifactId?: string;
258 | }
259 |
260 | async function handleWorkItems(
261 | options: WorkItemHandlingOptions,
262 | ): Promise<void> {
263 | const {
264 | connection,
265 | pullRequestId,
266 | repositoryId,
267 | projectId,
268 | workItemIdsToAdd,
269 | workItemIdsToRemove,
270 | artifactId,
271 | } = options;
272 |
273 | try {
274 | // For each work item to add, create a link
275 | if (workItemIdsToAdd.length > 0) {
276 | const workItemTrackingApi = await connection.getWorkItemTrackingApi();
277 |
278 | for (const workItemId of workItemIdsToAdd) {
279 | // Add the relationship between the work item and pull request
280 | await workItemTrackingApi.updateWorkItem(
281 | null,
282 | [
283 | {
284 | op: 'add',
285 | path: '/relations/-',
286 | value: {
287 | rel: 'ArtifactLink',
288 | // Use the artifactId if available, otherwise fall back to the old format
289 | url:
290 | artifactId ||
291 | `vstfs:///Git/PullRequestId/${projectId ?? ''}/${repositoryId}/${pullRequestId}`,
292 | attributes: {
293 | name: 'Pull Request',
294 | },
295 | },
296 | },
297 | ],
298 | workItemId,
299 | );
300 | }
301 | }
302 |
303 | // For each work item to remove, remove the link
304 | if (workItemIdsToRemove.length > 0) {
305 | const workItemTrackingApi = await connection.getWorkItemTrackingApi();
306 |
307 | for (const workItemId of workItemIdsToRemove) {
308 | try {
309 | // First, get the work item with relations expanded
310 | const workItem = await workItemTrackingApi.getWorkItem(
311 | workItemId,
312 | undefined, // fields
313 | undefined, // asOf
314 | WorkItemExpand.Relations,
315 | );
316 |
317 | if (workItem.relations) {
318 | // Find the relationship to the pull request using the artifactId
319 | const prRelationIndex = workItem.relations.findIndex(
320 | (rel: WorkItemRelation) =>
321 | rel.rel === 'ArtifactLink' &&
322 | rel.attributes &&
323 | rel.attributes.name === 'Pull Request' &&
324 | rel.url === artifactId,
325 | );
326 |
327 | if (prRelationIndex !== -1) {
328 | // Remove the relationship
329 | await workItemTrackingApi.updateWorkItem(
330 | null,
331 | [
332 | {
333 | op: 'remove',
334 | path: `/relations/${prRelationIndex}`,
335 | },
336 | ],
337 | workItemId,
338 | );
339 | }
340 | }
341 | } catch (error) {
342 | console.log(
343 | `Error removing work item ${workItemId} from pull request ${pullRequestId}: ${
344 | error instanceof Error ? error.message : String(error)
345 | }`,
346 | );
347 | }
348 | }
349 | }
350 | } catch (error) {
351 | throw new AzureDevOpsError(
352 | `Failed to update work item links for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`,
353 | );
354 | }
355 | }
356 |
357 | /**
358 | * Handle adding or removing reviewers from a pull request
359 | */
360 | interface ReviewerHandlingOptions {
361 | connection: WebApi;
362 | pullRequestId: number;
363 | repositoryId: string;
364 | projectId?: string;
365 | reviewersToAdd: string[];
366 | reviewersToRemove: string[];
367 | }
368 |
369 | async function handleReviewers(
370 | options: ReviewerHandlingOptions,
371 | ): Promise<void> {
372 | const {
373 | connection,
374 | pullRequestId,
375 | repositoryId,
376 | projectId,
377 | reviewersToAdd,
378 | reviewersToRemove,
379 | } = options;
380 |
381 | try {
382 | const gitApi = await connection.getGitApi();
383 |
384 | // Add reviewers
385 | if (reviewersToAdd.length > 0) {
386 | for (const reviewer of reviewersToAdd) {
387 | try {
388 | // Create a reviewer object with the identifier
389 | await gitApi.createPullRequestReviewer(
390 | {
391 | id: reviewer, // This can be email or ID
392 | isRequired: false,
393 | },
394 | repositoryId,
395 | pullRequestId,
396 | reviewer,
397 | projectId,
398 | );
399 | } catch (error) {
400 | console.log(
401 | `Error adding reviewer ${reviewer} to pull request ${pullRequestId}: ${
402 | error instanceof Error ? error.message : String(error)
403 | }`,
404 | );
405 | }
406 | }
407 | }
408 |
409 | // Remove reviewers
410 | if (reviewersToRemove.length > 0) {
411 | for (const reviewer of reviewersToRemove) {
412 | try {
413 | await gitApi.deletePullRequestReviewer(
414 | repositoryId,
415 | pullRequestId,
416 | reviewer,
417 | projectId,
418 | );
419 | } catch (error) {
420 | console.log(
421 | `Error removing reviewer ${reviewer} from pull request ${pullRequestId}: ${
422 | error instanceof Error ? error.message : String(error)
423 | }`,
424 | );
425 | }
426 | }
427 | }
428 | } catch (error) {
429 | throw new AzureDevOpsError(
430 | `Failed to update reviewers for pull request ${pullRequestId}: ${error instanceof Error ? error.message : String(error)}`,
431 | );
432 | }
433 | }
434 |
```
--------------------------------------------------------------------------------
/src/features/repositories/get-all-repositories-tree/feature.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { IGitApi } from 'azure-devops-node-api/GitApi';
3 | import {
4 | GitVersionType,
5 | VersionControlRecursionType,
6 | GitItem,
7 | GitObjectType,
8 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
9 | import { minimatch } from 'minimatch';
10 | import { AzureDevOpsError } from '../../../shared/errors';
11 | import {
12 | GetAllRepositoriesTreeOptions,
13 | AllRepositoriesTreeResponse,
14 | RepositoryTreeResponse,
15 | RepositoryTreeItem,
16 | GitRepository,
17 | } from '../types';
18 |
19 | /**
20 | * Get tree view of files/directories across multiple repositories
21 | *
22 | * @param connection The Azure DevOps WebApi connection
23 | * @param options Options for getting repository tree
24 | * @returns Tree structure for each repository
25 | */
26 | export async function getAllRepositoriesTree(
27 | connection: WebApi,
28 | options: GetAllRepositoriesTreeOptions,
29 | ): Promise<AllRepositoriesTreeResponse> {
30 | try {
31 | const gitApi = await connection.getGitApi();
32 | let repositories: GitRepository[] = [];
33 |
34 | // Get all repositories in the project
35 | repositories = await gitApi.getRepositories(options.projectId);
36 |
37 | // Filter repositories by name pattern if specified
38 | if (options.repositoryPattern) {
39 | repositories = repositories.filter((repo) =>
40 | minimatch(repo.name || '', options.repositoryPattern || '*'),
41 | );
42 | }
43 |
44 | // Initialize results array
45 | const results: RepositoryTreeResponse[] = [];
46 |
47 | // Process each repository
48 | for (const repo of repositories) {
49 | try {
50 | // Get default branch ref
51 | const defaultBranch = repo.defaultBranch;
52 | if (!defaultBranch) {
53 | // Skip repositories with no default branch
54 | results.push({
55 | name: repo.name || 'Unknown',
56 | tree: [],
57 | stats: { directories: 0, files: 0 },
58 | error: 'No default branch found',
59 | });
60 | continue;
61 | }
62 |
63 | // Clean the branch name (remove refs/heads/ prefix)
64 | const branchRef = defaultBranch.replace('refs/heads/', '');
65 |
66 | // Initialize tree items array and counters
67 | const treeItems: RepositoryTreeItem[] = [];
68 | const stats = { directories: 0, files: 0 };
69 |
70 | // Determine the recursion level and processing approach
71 | const depth = options.depth !== undefined ? options.depth : 0; // Default to 0 (max depth)
72 |
73 | if (depth === 0) {
74 | // For max depth (0), use server-side recursion for better performance
75 | const allItems = await gitApi.getItems(
76 | repo.id || '',
77 | options.projectId,
78 | '/',
79 | VersionControlRecursionType.Full, // Use full recursion
80 | true,
81 | false,
82 | false,
83 | false,
84 | {
85 | version: branchRef,
86 | versionType: GitVersionType.Branch,
87 | },
88 | );
89 |
90 | // Filter out the root item itself and bad items
91 | const itemsToProcess = allItems.filter(
92 | (item) =>
93 | item.path !== '/' && item.gitObjectType !== GitObjectType.Bad,
94 | );
95 |
96 | // Process all items at once (they're already retrieved recursively)
97 | processItemsNonRecursive(
98 | itemsToProcess,
99 | treeItems,
100 | stats,
101 | options.pattern,
102 | );
103 | } else {
104 | // For limited depth, use the regular recursive approach
105 | // Get items at the root level
106 | const rootItems = await gitApi.getItems(
107 | repo.id || '',
108 | options.projectId,
109 | '/',
110 | VersionControlRecursionType.OneLevel,
111 | true,
112 | false,
113 | false,
114 | false,
115 | {
116 | version: branchRef,
117 | versionType: GitVersionType.Branch,
118 | },
119 | );
120 |
121 | // Filter out the root item itself and bad items
122 | const itemsToProcess = rootItems.filter(
123 | (item) =>
124 | item.path !== '/' && item.gitObjectType !== GitObjectType.Bad,
125 | );
126 |
127 | // Process the root items and their children (up to specified depth)
128 | await processItems(
129 | gitApi,
130 | repo.id || '',
131 | options.projectId,
132 | itemsToProcess,
133 | branchRef,
134 | treeItems,
135 | stats,
136 | 1,
137 | depth,
138 | options.pattern,
139 | );
140 | }
141 |
142 | // Add repository tree to results
143 | results.push({
144 | name: repo.name || 'Unknown',
145 | tree: treeItems,
146 | stats,
147 | });
148 | } catch (repoError) {
149 | // Handle errors for individual repositories
150 | results.push({
151 | name: repo.name || 'Unknown',
152 | tree: [],
153 | stats: { directories: 0, files: 0 },
154 | error: `Error processing repository: ${repoError instanceof Error ? repoError.message : String(repoError)}`,
155 | });
156 | }
157 | }
158 |
159 | return { repositories: results };
160 | } catch (error) {
161 | if (error instanceof AzureDevOpsError) {
162 | throw error;
163 | }
164 | throw new Error(
165 | `Failed to get repository tree: ${error instanceof Error ? error.message : String(error)}`,
166 | );
167 | }
168 | }
169 |
170 | /**
171 | * Process items non-recursively when they're already retrieved with VersionControlRecursionType.Full
172 | */
173 | function processItemsNonRecursive(
174 | items: GitItem[],
175 | result: RepositoryTreeItem[],
176 | stats: { directories: number; files: number },
177 | pattern?: string,
178 | ): void {
179 | // Sort items (folders first, then by path)
180 | const sortedItems = [...items].sort((a, b) => {
181 | if (a.isFolder === b.isFolder) {
182 | return (a.path || '').localeCompare(b.path || '');
183 | }
184 | return a.isFolder ? -1 : 1;
185 | });
186 |
187 | for (const item of sortedItems) {
188 | const name = item.path?.split('/').pop() || '';
189 | const path = item.path || '';
190 | const isFolder = !!item.isFolder;
191 |
192 | // Skip the root folder
193 | if (path === '/') {
194 | continue;
195 | }
196 |
197 | // Calculate level from path segments
198 | // Remove leading '/' then count segments
199 | // For paths like:
200 | // /README.md -> ["README.md"] -> length 1 -> level 1
201 | // /src/index.ts -> ["src", "index.ts"] -> length 2 -> level 2
202 | // /src/utils/helper.ts -> ["src", "utils", "helper.ts"] -> length 3 -> level 3
203 | const pathSegments = path.replace(/^\//, '').split('/');
204 | const level = pathSegments.length;
205 |
206 | // Filter files based on pattern (if specified)
207 | if (!isFolder && pattern && !minimatch(name, pattern)) {
208 | continue;
209 | }
210 |
211 | // Add item to results
212 | result.push({
213 | name,
214 | path,
215 | isFolder,
216 | level,
217 | });
218 |
219 | // Update counters
220 | if (isFolder) {
221 | stats.directories++;
222 | } else {
223 | stats.files++;
224 | }
225 | }
226 | }
227 |
228 | /**
229 | * Process items recursively up to the specified depth
230 | */
231 | async function processItems(
232 | gitApi: IGitApi,
233 | repoId: string,
234 | projectId: string,
235 | items: GitItem[],
236 | branchRef: string,
237 | result: RepositoryTreeItem[],
238 | stats: { directories: number; files: number },
239 | currentDepth: number,
240 | maxDepth: number,
241 | pattern?: string,
242 | ): Promise<void> {
243 | // Sort items (directories first, then files)
244 | const sortedItems = [...items].sort((a, b) => {
245 | if (a.isFolder === b.isFolder) {
246 | return (a.path || '').localeCompare(b.path || '');
247 | }
248 | return a.isFolder ? -1 : 1;
249 | });
250 |
251 | for (const item of sortedItems) {
252 | const name = item.path?.split('/').pop() || '';
253 | const path = item.path || '';
254 | const isFolder = !!item.isFolder;
255 |
256 | // Filter files based on pattern (if specified)
257 | if (!isFolder && pattern && !minimatch(name, pattern)) {
258 | continue;
259 | }
260 |
261 | // Add item to results
262 | result.push({
263 | name,
264 | path,
265 | isFolder,
266 | level: currentDepth,
267 | });
268 |
269 | // Update counters
270 | if (isFolder) {
271 | stats.directories++;
272 | } else {
273 | stats.files++;
274 | }
275 |
276 | // Recursively process folders if not yet at max depth
277 | if (isFolder && currentDepth < maxDepth) {
278 | try {
279 | const childItems = await gitApi.getItems(
280 | repoId,
281 | projectId,
282 | path,
283 | VersionControlRecursionType.OneLevel,
284 | true,
285 | false,
286 | false,
287 | false,
288 | {
289 | version: branchRef,
290 | versionType: GitVersionType.Branch,
291 | },
292 | );
293 |
294 | // Filter out the parent folder itself and bad items
295 | const itemsToProcess = childItems.filter(
296 | (child: GitItem) =>
297 | child.path !== path && child.gitObjectType !== GitObjectType.Bad,
298 | );
299 |
300 | // Process child items
301 | await processItems(
302 | gitApi,
303 | repoId,
304 | projectId,
305 | itemsToProcess,
306 | branchRef,
307 | result,
308 | stats,
309 | currentDepth + 1,
310 | maxDepth,
311 | pattern,
312 | );
313 | } catch (error) {
314 | // Ignore errors in child items and continue with siblings
315 | console.error(`Error processing folder ${path}: ${error}`);
316 | }
317 | }
318 | }
319 | }
320 |
321 | /**
322 | * Convert the tree items to a formatted ASCII string representation
323 | *
324 | * @param repoName Repository name
325 | * @param items Tree items
326 | * @param stats Statistics about files and directories
327 | * @returns Formatted ASCII string
328 | */
329 | export function formatRepositoryTree(
330 | repoName: string,
331 | items: RepositoryTreeItem[],
332 | stats: { directories: number; files: number },
333 | error?: string,
334 | ): string {
335 | let output = `${repoName}/\n`;
336 |
337 | if (error) {
338 | output += ` (${error})\n`;
339 | } else if (items.length === 0) {
340 | output += ' (Repository is empty or default branch not found)\n';
341 | } else {
342 | // Sort items by path to ensure proper sequence
343 | const sortedItems = [...items].sort((a, b) => {
344 | // Sort by level first
345 | if (a.level !== b.level) {
346 | return a.level - b.level;
347 | }
348 | // Then folders before files
349 | if (a.isFolder !== b.isFolder) {
350 | return a.isFolder ? -1 : 1;
351 | }
352 | // Then alphabetically
353 | return a.path.localeCompare(b.path);
354 | });
355 |
356 | // Create a structured tree representation
357 | const tree = createTreeStructure(sortedItems);
358 |
359 | // Format the tree starting from the root
360 | output += formatTree(tree, ' ');
361 | }
362 |
363 | // Add summary line
364 | output += `${stats.directories} directories, ${stats.files} files\n`;
365 |
366 | return output;
367 | }
368 |
369 | /**
370 | * Create a structured tree from the flat list of items
371 | */
372 | function createTreeStructure(items: RepositoryTreeItem[]): TreeNode {
373 | const root: TreeNode = {
374 | name: '',
375 | path: '',
376 | isFolder: true,
377 | children: [],
378 | };
379 |
380 | // Map to track all nodes by path
381 | const nodeMap: Record<string, TreeNode> = { '': root };
382 |
383 | // First create all nodes
384 | for (const item of items) {
385 | nodeMap[item.path] = {
386 | name: item.name,
387 | path: item.path,
388 | isFolder: item.isFolder,
389 | children: [],
390 | };
391 | }
392 |
393 | // Then build the hierarchy
394 | for (const item of items) {
395 | if (item.path === '/') continue;
396 |
397 | const node = nodeMap[item.path];
398 | const lastSlashIndex = item.path.lastIndexOf('/');
399 |
400 | // For root level items, the parent path is empty
401 | const parentPath =
402 | lastSlashIndex <= 0 ? '' : item.path.substring(0, lastSlashIndex);
403 |
404 | // Get parent node (defaults to root if parent not found)
405 | const parent = nodeMap[parentPath] || root;
406 |
407 | // Add this node as a child of its parent
408 | parent.children.push(node);
409 | }
410 |
411 | return root;
412 | }
413 |
414 | /**
415 | * Format a tree structure into an ASCII tree representation
416 | */
417 | function formatTree(node: TreeNode, indent: string): string {
418 | if (!node.children.length) return '';
419 |
420 | let output = '';
421 |
422 | // Sort the children: folders first, then alphabetically
423 | const children = [...node.children].sort((a, b) => {
424 | if (a.isFolder !== b.isFolder) {
425 | return a.isFolder ? -1 : 1;
426 | }
427 | return a.name.localeCompare(b.name);
428 | });
429 |
430 | // Format each child node
431 | for (let i = 0; i < children.length; i++) {
432 | const child = children[i];
433 | const isLast = i === children.length - 1;
434 | const connector = isLast ? '`-- ' : '|-- ';
435 | const childIndent = isLast ? ' ' : '| ';
436 |
437 | // Add the node itself
438 | const suffix = child.isFolder ? '/' : '';
439 | output += `${indent}${connector}${child.name}${suffix}\n`;
440 |
441 | // Recursively add its children
442 | if (child.children.length > 0) {
443 | output += formatTree(child, indent + childIndent);
444 | }
445 | }
446 |
447 | return output;
448 | }
449 |
450 | /**
451 | * Tree node interface for hierarchical representation
452 | */
453 | interface TreeNode {
454 | name: string;
455 | path: string;
456 | isFolder: boolean;
457 | children: TreeNode[];
458 | }
459 |
```
--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-comments/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import { getPullRequestComments } from './feature';
3 | import { GitPullRequestCommentThread } from 'azure-devops-node-api/interfaces/GitInterfaces';
4 |
5 | describe('getPullRequestComments', () => {
6 | afterEach(() => {
7 | jest.resetAllMocks();
8 | });
9 |
10 | test('should return pull request comment threads with file path and line number', async () => {
11 | // Mock data for a comment thread
12 | const mockCommentThreads: GitPullRequestCommentThread[] = [
13 | {
14 | id: 1,
15 | status: 1, // Active
16 | threadContext: {
17 | filePath: '/src/app.ts',
18 | rightFileStart: {
19 | line: 10,
20 | offset: 5,
21 | },
22 | rightFileEnd: {
23 | line: 10,
24 | offset: 15,
25 | },
26 | },
27 | comments: [
28 | {
29 | id: 100,
30 | content: 'This code needs refactoring',
31 | commentType: 1, // CodeChange
32 | author: {
33 | displayName: 'Test User',
34 | id: 'test-user-id',
35 | },
36 | publishedDate: new Date(),
37 | },
38 | {
39 | id: 101,
40 | parentCommentId: 100,
41 | content: 'I agree, will update',
42 | commentType: 1, // CodeChange
43 | author: {
44 | displayName: 'Another User',
45 | id: 'another-user-id',
46 | },
47 | publishedDate: new Date(),
48 | },
49 | ],
50 | },
51 | ];
52 |
53 | // Setup mock connection
54 | const mockGitApi = {
55 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads),
56 | getPullRequestThread: jest.fn(),
57 | };
58 |
59 | const mockConnection: any = {
60 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
61 | };
62 |
63 | // Call the function with test parameters
64 | const projectId = 'test-project';
65 | const repositoryId = 'test-repo';
66 | const pullRequestId = 123;
67 | const options = {
68 | projectId,
69 | repositoryId,
70 | pullRequestId,
71 | };
72 |
73 | const result = await getPullRequestComments(
74 | mockConnection as WebApi,
75 | projectId,
76 | repositoryId,
77 | pullRequestId,
78 | options,
79 | );
80 |
81 | // Verify results
82 | expect(result).toHaveLength(1);
83 | expect(result[0].comments).toHaveLength(2);
84 |
85 | // Verify file path and line number are added to each comment
86 | result[0].comments?.forEach((comment) => {
87 | expect(comment).toHaveProperty('filePath', '/src/app.ts');
88 | expect(comment).toHaveProperty('rightFileStart', { line: 10, offset: 5 });
89 | expect(comment).toHaveProperty('rightFileEnd', { line: 10, offset: 15 });
90 | expect(comment).toHaveProperty('leftFileStart', undefined);
91 | expect(comment).toHaveProperty('leftFileEnd', undefined);
92 | });
93 |
94 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1);
95 | expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1);
96 | expect(mockGitApi.getThreads).toHaveBeenCalledWith(
97 | repositoryId,
98 | pullRequestId,
99 | projectId,
100 | undefined,
101 | undefined,
102 | );
103 | expect(mockGitApi.getPullRequestThread).not.toHaveBeenCalled();
104 | });
105 |
106 | test('should handle comments without thread context', async () => {
107 | // Mock data for a comment thread without thread context
108 | const mockCommentThreads: GitPullRequestCommentThread[] = [
109 | {
110 | id: 1,
111 | status: 1, // Active
112 | comments: [
113 | {
114 | id: 100,
115 | content: 'General comment',
116 | commentType: 1,
117 | author: {
118 | displayName: 'Test User',
119 | id: 'test-user-id',
120 | },
121 | publishedDate: new Date(),
122 | },
123 | ],
124 | },
125 | ];
126 |
127 | // Setup mock connection
128 | const mockGitApi = {
129 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads),
130 | getPullRequestThread: jest.fn(),
131 | };
132 |
133 | const mockConnection: any = {
134 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
135 | };
136 |
137 | const result = await getPullRequestComments(
138 | mockConnection as WebApi,
139 | 'test-project',
140 | 'test-repo',
141 | 123,
142 | {
143 | projectId: 'test-project',
144 | repositoryId: 'test-repo',
145 | pullRequestId: 123,
146 | },
147 | );
148 |
149 | // Verify results
150 | expect(result).toHaveLength(1);
151 | expect(result[0].comments).toHaveLength(1);
152 | expect(result[0].status).toBe('active');
153 |
154 | // Verify file path and line number are null for comments without thread context
155 | const comment = result[0].comments![0];
156 | expect(comment).toHaveProperty('filePath', undefined);
157 | expect(comment).toHaveProperty('rightFileStart', undefined);
158 | expect(comment).toHaveProperty('rightFileEnd', undefined);
159 | expect(comment).toHaveProperty('leftFileStart', undefined);
160 | expect(comment).toHaveProperty('leftFileEnd', undefined);
161 | expect(comment).toHaveProperty('commentType', 'text');
162 | });
163 |
164 | test('should use leftFileStart when rightFileStart is not available', async () => {
165 | // Mock data for a comment thread with only leftFileStart
166 | const mockCommentThreads: GitPullRequestCommentThread[] = [
167 | {
168 | id: 1,
169 | status: 1,
170 | threadContext: {
171 | filePath: '/src/app.ts',
172 | leftFileStart: {
173 | line: 5,
174 | offset: 1,
175 | },
176 | },
177 | comments: [
178 | {
179 | id: 100,
180 | content: 'Comment on deleted line',
181 | commentType: 1,
182 | author: {
183 | displayName: 'Test User',
184 | id: 'test-user-id',
185 | },
186 | publishedDate: new Date(),
187 | },
188 | ],
189 | },
190 | ];
191 |
192 | // Setup mock connection
193 | const mockGitApi = {
194 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads),
195 | getPullRequestThread: jest.fn(),
196 | };
197 |
198 | const mockConnection: any = {
199 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
200 | };
201 |
202 | const result = await getPullRequestComments(
203 | mockConnection as WebApi,
204 | 'test-project',
205 | 'test-repo',
206 | 123,
207 | {
208 | projectId: 'test-project',
209 | repositoryId: 'test-repo',
210 | pullRequestId: 123,
211 | },
212 | );
213 |
214 | // Verify results
215 | expect(result).toHaveLength(1);
216 | expect(result[0].comments).toHaveLength(1);
217 |
218 | // Verify rightFileStart is undefined, leftFileStart is present
219 | const comment = result[0].comments![0];
220 | expect(comment).toHaveProperty('filePath', '/src/app.ts');
221 | expect(comment).toHaveProperty('leftFileStart', { line: 5, offset: 1 });
222 | expect(comment).toHaveProperty('rightFileStart', undefined);
223 | expect(comment).toHaveProperty('leftFileEnd', undefined);
224 | expect(comment).toHaveProperty('rightFileEnd', undefined);
225 | });
226 |
227 | test('should return a specific comment thread when threadId is provided', async () => {
228 | // Mock data for a specific comment thread
229 | const threadId = 42;
230 | const mockCommentThread: GitPullRequestCommentThread = {
231 | id: threadId,
232 | status: 1, // Active
233 | threadContext: {
234 | filePath: '/src/utils.ts',
235 | rightFileStart: {
236 | line: 15,
237 | offset: 1,
238 | },
239 | },
240 | comments: [
241 | {
242 | id: 100,
243 | content: 'Specific comment',
244 | commentType: 1, // CodeChange
245 | author: {
246 | displayName: 'Test User',
247 | id: 'test-user-id',
248 | },
249 | publishedDate: new Date(),
250 | },
251 | ],
252 | };
253 |
254 | // Setup mock connection
255 | const mockGitApi = {
256 | getThreads: jest.fn(),
257 | getPullRequestThread: jest.fn().mockResolvedValue(mockCommentThread),
258 | };
259 |
260 | const mockConnection: any = {
261 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
262 | };
263 |
264 | // Call the function with test parameters
265 | const projectId = 'test-project';
266 | const repositoryId = 'test-repo';
267 | const pullRequestId = 123;
268 | const options = {
269 | projectId,
270 | repositoryId,
271 | pullRequestId,
272 | threadId,
273 | };
274 |
275 | const result = await getPullRequestComments(
276 | mockConnection as WebApi,
277 | projectId,
278 | repositoryId,
279 | pullRequestId,
280 | options,
281 | );
282 |
283 | // Verify results
284 | expect(result).toHaveLength(1);
285 | expect(result[0].id).toBe(threadId);
286 | expect(result[0].comments).toHaveLength(1);
287 |
288 | // Verify file path and line number are added
289 | const comment = result[0].comments![0];
290 | expect(comment).toHaveProperty('filePath', '/src/utils.ts');
291 | expect(comment).toHaveProperty('rightFileStart', { line: 15, offset: 1 });
292 | expect(comment).toHaveProperty('leftFileStart', undefined);
293 | expect(comment).toHaveProperty('leftFileEnd', undefined);
294 | expect(comment).toHaveProperty('rightFileEnd', undefined);
295 |
296 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1);
297 | expect(mockGitApi.getPullRequestThread).toHaveBeenCalledTimes(1);
298 | expect(mockGitApi.getPullRequestThread).toHaveBeenCalledWith(
299 | repositoryId,
300 | pullRequestId,
301 | threadId,
302 | projectId,
303 | );
304 | expect(mockGitApi.getThreads).not.toHaveBeenCalled();
305 | });
306 |
307 | test('should handle pagination when top parameter is provided', async () => {
308 | // Mock data for multiple comment threads
309 | const mockCommentThreads: GitPullRequestCommentThread[] = [
310 | {
311 | id: 1,
312 | status: 1,
313 | threadContext: {
314 | filePath: '/src/file1.ts',
315 | rightFileStart: { line: 1, offset: 1 },
316 | },
317 | comments: [{ id: 100, content: 'Comment 1' }],
318 | },
319 | {
320 | id: 2,
321 | status: 1,
322 | threadContext: {
323 | filePath: '/src/file2.ts',
324 | rightFileStart: { line: 2, offset: 1 },
325 | },
326 | comments: [{ id: 101, content: 'Comment 2' }],
327 | },
328 | {
329 | id: 3,
330 | status: 1,
331 | threadContext: {
332 | filePath: '/src/file3.ts',
333 | rightFileStart: { line: 3, offset: 1 },
334 | },
335 | comments: [{ id: 102, content: 'Comment 3' }],
336 | },
337 | ];
338 |
339 | // Setup mock connection
340 | const mockGitApi = {
341 | getThreads: jest.fn().mockResolvedValue(mockCommentThreads),
342 | getPullRequestThread: jest.fn(),
343 | };
344 |
345 | const mockConnection: any = {
346 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
347 | };
348 |
349 | // Call the function with test parameters and top=2
350 | const projectId = 'test-project';
351 | const repositoryId = 'test-repo';
352 | const pullRequestId = 123;
353 | const options = {
354 | projectId,
355 | repositoryId,
356 | pullRequestId,
357 | top: 2,
358 | };
359 |
360 | const result = await getPullRequestComments(
361 | mockConnection as WebApi,
362 | projectId,
363 | repositoryId,
364 | pullRequestId,
365 | options,
366 | );
367 |
368 | // Verify results (should only include first 2 threads)
369 | expect(result).toHaveLength(2);
370 | expect(result).toEqual(
371 | mockCommentThreads.slice(0, 2).map((thread) => ({
372 | ...thread,
373 | status: 'active', // Transform enum to string
374 | comments: thread.comments?.map((comment) => ({
375 | ...comment,
376 | commentType: undefined, // Will be undefined since mock doesn't have commentType
377 | filePath: thread.threadContext?.filePath,
378 | rightFileStart: thread.threadContext?.rightFileStart ?? undefined,
379 | rightFileEnd: thread.threadContext?.rightFileEnd ?? undefined,
380 | leftFileStart: thread.threadContext?.leftFileStart ?? undefined,
381 | leftFileEnd: thread.threadContext?.leftFileEnd ?? undefined,
382 | })),
383 | })),
384 | );
385 | expect(mockConnection.getGitApi).toHaveBeenCalledTimes(1);
386 | expect(mockGitApi.getThreads).toHaveBeenCalledTimes(1);
387 | expect(result[0].comments![0]).toHaveProperty('rightFileStart', {
388 | line: 1,
389 | offset: 1,
390 | });
391 | expect(result[1].comments![0]).toHaveProperty('rightFileStart', {
392 | line: 2,
393 | offset: 1,
394 | });
395 | });
396 |
397 | test('should handle error when API call fails', async () => {
398 | // Setup mock connection with error
399 | const errorMessage = 'API error';
400 | const mockGitApi = {
401 | getThreads: jest.fn().mockRejectedValue(new Error(errorMessage)),
402 | };
403 |
404 | const mockConnection: any = {
405 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
406 | };
407 |
408 | // Call the function with test parameters
409 | const projectId = 'test-project';
410 | const repositoryId = 'test-repo';
411 | const pullRequestId = 123;
412 | const options = {
413 | projectId,
414 | repositoryId,
415 | pullRequestId,
416 | };
417 |
418 | // Verify error handling
419 | await expect(
420 | getPullRequestComments(
421 | mockConnection as WebApi,
422 | projectId,
423 | repositoryId,
424 | pullRequestId,
425 | options,
426 | ),
427 | ).rejects.toThrow(`Failed to get pull request comments: ${errorMessage}`);
428 | });
429 | });
430 |
```
--------------------------------------------------------------------------------
/src/features/repositories/get-all-repositories-tree/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { WebApi } from 'azure-devops-node-api';
2 | import {
3 | GitObjectType,
4 | VersionControlRecursionType,
5 | } from 'azure-devops-node-api/interfaces/GitInterfaces';
6 | import { getAllRepositoriesTree, formatRepositoryTree } from './feature';
7 | import { RepositoryTreeItem } from '../types';
8 |
9 | // Mock the Azure DevOps API
10 | jest.mock('azure-devops-node-api');
11 |
12 | describe('getAllRepositoriesTree', () => {
13 | // Sample repositories
14 | const mockRepos = [
15 | {
16 | id: 'repo1-id',
17 | name: 'repo1',
18 | defaultBranch: 'refs/heads/main',
19 | },
20 | {
21 | id: 'repo2-id',
22 | name: 'repo2',
23 | defaultBranch: 'refs/heads/master',
24 | },
25 | {
26 | id: 'repo3-id',
27 | name: 'repo3-api',
28 | defaultBranch: null, // No default branch
29 | },
30 | ];
31 |
32 | // Sample files/folders for repo1 at root level
33 | const mockRepo1RootItems = [
34 | {
35 | path: '/',
36 | gitObjectType: GitObjectType.Tree,
37 | },
38 | {
39 | path: '/README.md',
40 | isFolder: false,
41 | gitObjectType: GitObjectType.Blob,
42 | },
43 | {
44 | path: '/src',
45 | isFolder: true,
46 | gitObjectType: GitObjectType.Tree,
47 | },
48 | {
49 | path: '/package.json',
50 | isFolder: false,
51 | gitObjectType: GitObjectType.Blob,
52 | },
53 | ];
54 |
55 | // Sample files/folders for repo1 - src folder
56 | const mockRepo1SrcItems = [
57 | {
58 | path: '/src',
59 | isFolder: true,
60 | gitObjectType: GitObjectType.Tree,
61 | },
62 | {
63 | path: '/src/index.ts',
64 | isFolder: false,
65 | gitObjectType: GitObjectType.Blob,
66 | },
67 | {
68 | path: '/src/utils',
69 | isFolder: true,
70 | gitObjectType: GitObjectType.Tree,
71 | },
72 | ];
73 |
74 | // Sample files/folders for repo1 with unlimited depth (what server would return for Full recursion)
75 | const mockRepo1FullRecursionItems = [
76 | {
77 | path: '/',
78 | gitObjectType: GitObjectType.Tree,
79 | },
80 | {
81 | path: '/README.md',
82 | isFolder: false,
83 | gitObjectType: GitObjectType.Blob,
84 | },
85 | {
86 | path: '/src',
87 | isFolder: true,
88 | gitObjectType: GitObjectType.Tree,
89 | },
90 | {
91 | path: '/package.json',
92 | isFolder: false,
93 | gitObjectType: GitObjectType.Blob,
94 | },
95 | {
96 | path: '/src/index.ts',
97 | isFolder: false,
98 | gitObjectType: GitObjectType.Blob,
99 | },
100 | {
101 | path: '/src/utils',
102 | isFolder: true,
103 | gitObjectType: GitObjectType.Tree,
104 | },
105 | {
106 | path: '/src/utils/helper.ts',
107 | isFolder: false,
108 | gitObjectType: GitObjectType.Blob,
109 | },
110 | {
111 | path: '/src/utils/constants.ts',
112 | isFolder: false,
113 | gitObjectType: GitObjectType.Blob,
114 | },
115 | ];
116 |
117 | // Sample files/folders for repo2
118 | const mockRepo2RootItems = [
119 | {
120 | path: '/',
121 | gitObjectType: GitObjectType.Tree,
122 | },
123 | {
124 | path: '/README.md',
125 | isFolder: false,
126 | gitObjectType: GitObjectType.Blob,
127 | },
128 | {
129 | path: '/data.json',
130 | isFolder: false,
131 | gitObjectType: GitObjectType.Blob,
132 | },
133 | ];
134 |
135 | let mockConnection: jest.Mocked<WebApi>;
136 | let mockGitApi: any;
137 |
138 | beforeEach(() => {
139 | // Clear mocks
140 | jest.clearAllMocks();
141 |
142 | // Create mock GitApi
143 | mockGitApi = {
144 | getRepositories: jest.fn().mockResolvedValue(mockRepos),
145 | getItems: jest
146 | .fn()
147 | .mockImplementation((repoId, _projectId, path, recursionLevel) => {
148 | if (repoId === 'repo1-id') {
149 | if (recursionLevel === VersionControlRecursionType.Full) {
150 | return Promise.resolve(mockRepo1FullRecursionItems);
151 | } else if (path === '/') {
152 | return Promise.resolve(mockRepo1RootItems);
153 | } else if (path === '/src') {
154 | return Promise.resolve(mockRepo1SrcItems);
155 | }
156 | } else if (repoId === 'repo2-id') {
157 | if (recursionLevel === VersionControlRecursionType.Full) {
158 | return Promise.resolve(mockRepo2RootItems);
159 | } else if (path === '/') {
160 | return Promise.resolve(mockRepo2RootItems);
161 | }
162 | }
163 | return Promise.resolve([]);
164 | }),
165 | };
166 |
167 | // Create mock connection
168 | mockConnection = {
169 | getGitApi: jest.fn().mockResolvedValue(mockGitApi),
170 | } as unknown as jest.Mocked<WebApi>;
171 | });
172 |
173 | it('should return tree structures for multiple repositories with limited depth', async () => {
174 | // Arrange
175 | const options = {
176 | organizationId: 'testOrg',
177 | projectId: 'testProject',
178 | depth: 2, // Limited depth
179 | };
180 |
181 | // Act
182 | const result = await getAllRepositoriesTree(mockConnection, options);
183 |
184 | // Assert
185 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject');
186 | expect(result.repositories.length).toBe(3);
187 |
188 | // Verify repo1 tree
189 | const repo1 = result.repositories.find((r) => r.name === 'repo1');
190 | expect(repo1).toBeDefined();
191 | expect(repo1?.tree.length).toBeGreaterThan(0);
192 | expect(repo1?.stats.directories).toBeGreaterThan(0);
193 | expect(repo1?.stats.files).toBeGreaterThan(0);
194 |
195 | // Verify repo2 tree
196 | const repo2 = result.repositories.find((r) => r.name === 'repo2');
197 | expect(repo2).toBeDefined();
198 | expect(repo2?.tree.length).toBeGreaterThan(0);
199 |
200 | // Verify repo3 has error (no default branch)
201 | const repo3 = result.repositories.find((r) => r.name === 'repo3-api');
202 | expect(repo3).toBeDefined();
203 | expect(repo3?.error).toContain('No default branch found');
204 |
205 | // Verify recursion level was set correctly
206 | expect(mockGitApi.getItems).toHaveBeenCalledWith(
207 | 'repo1-id',
208 | 'testProject',
209 | '/',
210 | VersionControlRecursionType.OneLevel,
211 | expect.anything(),
212 | expect.anything(),
213 | expect.anything(),
214 | expect.anything(),
215 | expect.anything(),
216 | );
217 | });
218 |
219 | it('should return tree structures with max depth using Full recursion', async () => {
220 | // Arrange
221 | const options = {
222 | organizationId: 'testOrg',
223 | projectId: 'testProject',
224 | depth: 0, // Max depth
225 | };
226 |
227 | // Act
228 | const result = await getAllRepositoriesTree(mockConnection, options);
229 |
230 | // Assert
231 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject');
232 | expect(result.repositories.length).toBe(3);
233 |
234 | // Verify repo1 tree
235 | const repo1 = result.repositories.find((r) => r.name === 'repo1');
236 | expect(repo1).toBeDefined();
237 | expect(repo1?.tree.length).toBeGreaterThan(0);
238 | // Should include all items, including nested ones
239 | expect(repo1?.tree.length).toBe(mockRepo1FullRecursionItems.length - 1); // -1 for root folder
240 |
241 | // Verify recursion level was set correctly
242 | expect(mockGitApi.getItems).toHaveBeenCalledWith(
243 | 'repo1-id',
244 | 'testProject',
245 | '/',
246 | VersionControlRecursionType.Full,
247 | expect.anything(),
248 | expect.anything(),
249 | expect.anything(),
250 | expect.anything(),
251 | expect.anything(),
252 | );
253 |
254 | // Verify all levels are represented
255 | if (repo1) {
256 | const level1Items = repo1.tree.filter((item) => item.level === 1);
257 | const level2Items = repo1.tree.filter((item) => item.level === 2);
258 | const level3Items = repo1.tree.filter((item) => item.level === 3);
259 |
260 | // Verify we have items at level 1
261 | expect(level1Items.length).toBeGreaterThan(0);
262 |
263 | // Verify we have items at level 2 (src/something)
264 | expect(level2Items.length).toBeGreaterThan(0);
265 |
266 | // Check for level 3 items if they exist in our mock data
267 | if (
268 | mockRepo1FullRecursionItems.some((item) => {
269 | const pathSegments = item.path.split('/').filter(Boolean);
270 | return pathSegments.length >= 3;
271 | })
272 | ) {
273 | expect(level3Items.length).toBeGreaterThan(0);
274 | }
275 | }
276 | });
277 |
278 | it('should filter repositories by pattern', async () => {
279 | // Arrange
280 | const options = {
281 | organizationId: 'testOrg',
282 | projectId: 'testProject',
283 | repositoryPattern: '*api*',
284 | depth: 1,
285 | };
286 |
287 | // Act
288 | const result = await getAllRepositoriesTree(mockConnection, options);
289 |
290 | // Assert
291 | expect(mockGitApi.getRepositories).toHaveBeenCalledWith('testProject');
292 | expect(result.repositories.length).toBe(1);
293 | expect(result.repositories[0].name).toBe('repo3-api');
294 | });
295 |
296 | it('should format repository tree correctly', () => {
297 | // Arrange
298 | const treeItems: RepositoryTreeItem[] = [
299 | { name: 'src', path: '/src', isFolder: true, level: 1 },
300 | { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 },
301 | { name: 'README.md', path: '/README.md', isFolder: false, level: 1 },
302 | ];
303 | const stats = { directories: 1, files: 2 };
304 |
305 | // Act
306 | const formatted = formatRepositoryTree('test-repo', treeItems, stats);
307 |
308 | // Assert
309 | expect(formatted).toMatchSnapshot();
310 | });
311 |
312 | it('should format complex repository tree structures correctly', () => {
313 | // Arrange
314 | const treeItems: RepositoryTreeItem[] = [
315 | // Root level files
316 | { name: 'README.md', path: '/README.md', isFolder: false, level: 1 },
317 | {
318 | name: 'package.json',
319 | path: '/package.json',
320 | isFolder: false,
321 | level: 1,
322 | },
323 | { name: '.gitignore', path: '/.gitignore', isFolder: false, level: 1 },
324 |
325 | // Multiple folders at root level
326 | { name: 'src', path: '/src', isFolder: true, level: 1 },
327 | { name: 'tests', path: '/tests', isFolder: true, level: 1 },
328 | { name: 'docs', path: '/docs', isFolder: true, level: 1 },
329 |
330 | // Nested src folder structure
331 | { name: 'components', path: '/src/components', isFolder: true, level: 2 },
332 | { name: 'utils', path: '/src/utils', isFolder: true, level: 2 },
333 | { name: 'index.ts', path: '/src/index.ts', isFolder: false, level: 2 },
334 |
335 | // Deeply nested components
336 | {
337 | name: 'Button',
338 | path: '/src/components/Button',
339 | isFolder: true,
340 | level: 3,
341 | },
342 | { name: 'Card', path: '/src/components/Card', isFolder: true, level: 3 },
343 | {
344 | name: 'Button.tsx',
345 | path: '/src/components/Button/Button.tsx',
346 | isFolder: false,
347 | level: 4,
348 | },
349 | {
350 | name: 'Button.styles.ts',
351 | path: '/src/components/Button/Button.styles.ts',
352 | isFolder: false,
353 | level: 4,
354 | },
355 | {
356 | name: 'Button.test.tsx',
357 | path: '/src/components/Button/Button.test.tsx',
358 | isFolder: false,
359 | level: 4,
360 | },
361 | {
362 | name: 'index.ts',
363 | path: '/src/components/Button/index.ts',
364 | isFolder: false,
365 | level: 4,
366 | },
367 | {
368 | name: 'Card.tsx',
369 | path: '/src/components/Card/Card.tsx',
370 | isFolder: false,
371 | level: 4,
372 | },
373 |
374 | // Utils with files
375 | {
376 | name: 'helpers.ts',
377 | path: '/src/utils/helpers.ts',
378 | isFolder: false,
379 | level: 3,
380 | },
381 | {
382 | name: 'constants.ts',
383 | path: '/src/utils/constants.ts',
384 | isFolder: false,
385 | level: 3,
386 | },
387 |
388 | // Empty folder
389 | { name: 'assets', path: '/src/assets', isFolder: true, level: 2 },
390 |
391 | // Files with special characters
392 | {
393 | name: 'file-with-dashes.js',
394 | path: '/src/file-with-dashes.js',
395 | isFolder: false,
396 | level: 2,
397 | },
398 | {
399 | name: 'file_with_underscores.js',
400 | path: '/src/file_with_underscores.js',
401 | isFolder: false,
402 | level: 2,
403 | },
404 |
405 | // Folders in test directory
406 | { name: 'unit', path: '/tests/unit', isFolder: true, level: 2 },
407 | {
408 | name: 'integration',
409 | path: '/tests/integration',
410 | isFolder: true,
411 | level: 2,
412 | },
413 |
414 | // Files in test directories
415 | { name: 'setup.js', path: '/tests/setup.js', isFolder: false, level: 2 },
416 | {
417 | name: 'example.test.js',
418 | path: '/tests/unit/example.test.js',
419 | isFolder: false,
420 | level: 3,
421 | },
422 |
423 | // Files in docs
424 | { name: 'API.md', path: '/docs/API.md', isFolder: false, level: 2 },
425 | {
426 | name: 'CONTRIBUTING.md',
427 | path: '/docs/CONTRIBUTING.md',
428 | isFolder: false,
429 | level: 2,
430 | },
431 | ];
432 |
433 | const stats = { directories: 10, files: 18 };
434 |
435 | // Act
436 | const formatted = formatRepositoryTree('complex-repo', treeItems, stats);
437 |
438 | // Assert
439 | expect(formatted).toMatchSnapshot();
440 | });
441 |
442 | it('should handle repository errors gracefully', async () => {
443 | // Arrange
444 | mockGitApi.getItems = jest.fn().mockRejectedValue(new Error('API error'));
445 |
446 | const options = {
447 | organizationId: 'testOrg',
448 | projectId: 'testProject',
449 | depth: 1,
450 | };
451 |
452 | // Act
453 | const result = await getAllRepositoriesTree(mockConnection, options);
454 |
455 | // Assert
456 | expect(result.repositories.length).toBe(3);
457 | const repo1 = result.repositories.find((r) => r.name === 'repo1');
458 | expect(repo1?.error).toBeDefined();
459 | });
460 | });
461 |
```