#
tokens: 47959/50000 12/335 files (page 7/10)
lines: on (toggle) GitHub
raw markdown copy reset
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 | 
```
Page 7/10FirstPrevNextLast