#
tokens: 49067/50000 15/335 files (page 6/10)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 6 of 10. Use http://codebase.md/tiberriver256/azure-devops-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .clinerules
├── .env.example
├── .eslintrc.json
├── .github
│   ├── copilot-instructions.md
│   ├── FUNDING.yml
│   ├── release-please-config.json
│   ├── release-please-manifest.json
│   ├── skills
│   │   ├── azure-devops-rest-api
│   │   │   ├── references
│   │   │   │   └── api_areas.md
│   │   │   ├── scripts
│   │   │   │   ├── clone_specs.sh
│   │   │   │   └── find_endpoint.py
│   │   │   └── SKILL.md
│   │   └── skill-creator
│   │       ├── LICENSE.txt
│   │       ├── references
│   │       │   ├── output-patterns.md
│   │       │   └── workflows.md
│   │       ├── scripts
│   │       │   ├── init_skill.py
│   │       │   └── quick_validate.py
│   │       └── SKILL.md
│   └── workflows
│       ├── main.yml
│       ├── release-please.yml
│       └── update-skills.yml
├── .gitignore
├── .husky
│   ├── commit-msg
│   └── pre-commit
├── .kilocode
│   └── mcp.json
├── .prettierrc
├── .vscode
│   └── settings.json
├── CHANGELOG.md
├── commitlint.config.js
├── CONTRIBUTING.md
├── create_branch.sh
├── docs
│   ├── authentication.md
│   ├── azure-identity-authentication.md
│   ├── ci-setup.md
│   ├── examples
│   │   ├── azure-cli-authentication.env
│   │   ├── azure-identity-authentication.env
│   │   ├── pat-authentication.env
│   │   └── README.md
│   ├── testing
│   │   ├── README.md
│   │   └── setup.md
│   └── tools
│       ├── core-navigation.md
│       ├── organizations.md
│       ├── pipelines.md
│       ├── projects.md
│       ├── pull-requests.md
│       ├── README.md
│       ├── repositories.md
│       ├── resources.md
│       ├── search.md
│       ├── user-tools.md
│       ├── wiki.md
│       └── work-items.md
├── finish_task.sh
├── jest.e2e.config.js
├── jest.int.config.js
├── jest.unit.config.js
├── LICENSE
├── memory
│   └── tasks_memory_2025-05-26T16-18-03.json
├── package-lock.json
├── package.json
├── project-management
│   ├── planning
│   │   ├── architecture-guide.md
│   │   ├── azure-identity-authentication-design.md
│   │   ├── project-plan.md
│   │   ├── project-structure.md
│   │   ├── tech-stack.md
│   │   └── the-dream-team.md
│   ├── startup.xml
│   ├── tdd-cycle.xml
│   └── troubleshooter.xml
├── README.md
├── setup_env.sh
├── shrimp-rules.md
├── src
│   ├── clients
│   │   └── azure-devops.ts
│   ├── features
│   │   ├── organizations
│   │   │   ├── __test__
│   │   │   │   └── test-helpers.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-organizations
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── schemas.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── types.ts
│   │   ├── pipelines
│   │   │   ├── artifacts.spec.unit.ts
│   │   │   ├── artifacts.ts
│   │   │   ├── download-pipeline-artifact
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-pipeline
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-pipeline-log
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-pipeline-run
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── helpers.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-pipeline-runs
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── list-pipelines
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── pipeline-timeline
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── tool-definitions.ts
│   │   │   ├── trigger-pipeline
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   └── types.ts
│   │   ├── projects
│   │   │   ├── __test__
│   │   │   │   └── test-helpers.ts
│   │   │   ├── get-project
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-project-details
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-projects
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── schemas.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── types.ts
│   │   ├── pull-requests
│   │   │   ├── add-pull-request-comment
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── create-pull-request
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-pull-request-changes
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── get-pull-request-checks
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── get-pull-request-comments
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-pull-requests
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── schemas.ts
│   │   │   ├── tool-definitions.ts
│   │   │   ├── types.ts
│   │   │   └── update-pull-request
│   │   │       ├── feature.spec.int.ts
│   │   │       ├── feature.spec.unit.ts
│   │   │       ├── feature.ts
│   │   │       └── index.ts
│   │   ├── repositories
│   │   │   ├── __test__
│   │   │   │   └── test-helpers.ts
│   │   │   ├── create-branch
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── create-commit
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── get-all-repositories-tree
│   │   │   │   ├── __snapshots__
│   │   │   │   │   └── feature.spec.unit.ts.snap
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-file-content
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-repository
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-repository-details
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-repository-tree
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-commits
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── list-repositories
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── schemas.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── types.ts
│   │   ├── search
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── schemas.ts
│   │   │   ├── search-code
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── search-wiki
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── search-work-items
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   └── index.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── types.ts
│   │   ├── users
│   │   │   ├── get-me
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── schemas.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── types.ts
│   │   ├── wikis
│   │   │   ├── create-wiki
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── create-wiki-page
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-wiki-page
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── get-wikis
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── index.spec.unit.ts
│   │   │   ├── index.ts
│   │   │   ├── list-wiki-pages
│   │   │   │   ├── feature.spec.int.ts
│   │   │   │   ├── feature.spec.unit.ts
│   │   │   │   ├── feature.ts
│   │   │   │   ├── index.ts
│   │   │   │   └── schema.ts
│   │   │   ├── tool-definitions.ts
│   │   │   └── update-wiki-page
│   │   │       ├── feature.spec.int.ts
│   │   │       ├── feature.ts
│   │   │       ├── index.ts
│   │   │       └── schema.ts
│   │   └── work-items
│   │       ├── __test__
│   │       │   ├── fixtures.ts
│   │       │   ├── test-helpers.ts
│   │       │   └── test-utils.ts
│   │       ├── create-work-item
│   │       │   ├── feature.spec.int.ts
│   │       │   ├── feature.spec.unit.ts
│   │       │   ├── feature.ts
│   │       │   ├── index.ts
│   │       │   └── schema.ts
│   │       ├── get-work-item
│   │       │   ├── feature.spec.int.ts
│   │       │   ├── feature.spec.unit.ts
│   │       │   ├── feature.ts
│   │       │   ├── index.ts
│   │       │   └── schema.ts
│   │       ├── index.spec.unit.ts
│   │       ├── index.ts
│   │       ├── list-work-items
│   │       │   ├── feature.spec.int.ts
│   │       │   ├── feature.spec.unit.ts
│   │       │   ├── feature.ts
│   │       │   ├── index.ts
│   │       │   └── schema.ts
│   │       ├── manage-work-item-link
│   │       │   ├── feature.spec.int.ts
│   │       │   ├── feature.spec.unit.ts
│   │       │   ├── feature.ts
│   │       │   ├── index.ts
│   │       │   └── schema.ts
│   │       ├── schemas.ts
│   │       ├── tool-definitions.ts
│   │       ├── types.ts
│   │       └── update-work-item
│   │           ├── feature.spec.int.ts
│   │           ├── feature.spec.unit.ts
│   │           ├── feature.ts
│   │           ├── index.ts
│   │           └── schema.ts
│   ├── index.spec.unit.ts
│   ├── index.ts
│   ├── server.spec.e2e.ts
│   ├── server.ts
│   ├── shared
│   │   ├── api
│   │   │   ├── client.ts
│   │   │   └── index.ts
│   │   ├── auth
│   │   │   ├── auth-factory.ts
│   │   │   ├── client-factory.ts
│   │   │   └── index.ts
│   │   ├── config
│   │   │   ├── index.ts
│   │   │   └── version.ts
│   │   ├── enums
│   │   │   ├── index.spec.unit.ts
│   │   │   └── index.ts
│   │   ├── errors
│   │   │   ├── azure-devops-errors.ts
│   │   │   ├── handle-request-error.ts
│   │   │   └── index.ts
│   │   ├── test
│   │   │   └── test-helpers.ts
│   │   └── types
│   │       ├── config.ts
│   │       ├── index.ts
│   │       ├── request-handler.ts
│   │       └── tool-definition.ts
│   ├── types
│   │   └── diff.d.ts
│   └── utils
│       ├── environment.spec.unit.ts
│       └── environment.ts
├── tasks.json
├── tests
│   └── setup.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/src/features/pipelines/index.spec.unit.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { WebApi } from 'azure-devops-node-api';
  2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
  3 | import { isPipelinesRequest, handlePipelinesRequest } from './index';
  4 | import { listPipelines } from './list-pipelines/feature';
  5 | import { getPipeline } from './get-pipeline/feature';
  6 | import { listPipelineRuns } from './list-pipeline-runs/feature';
  7 | import { getPipelineRun } from './get-pipeline-run/feature';
  8 | import { getPipelineTimeline } from './pipeline-timeline/feature';
  9 | import { getPipelineLog } from './get-pipeline-log/feature';
 10 | import { triggerPipeline } from './trigger-pipeline/feature';
 11 | 
 12 | jest.mock('./list-pipelines/feature');
 13 | jest.mock('./get-pipeline/feature');
 14 | jest.mock('./list-pipeline-runs/feature');
 15 | jest.mock('./get-pipeline-run/feature');
 16 | jest.mock('./pipeline-timeline/feature');
 17 | jest.mock('./get-pipeline-log/feature');
 18 | jest.mock('./trigger-pipeline/feature');
 19 | 
 20 | describe('Pipelines Request Handlers', () => {
 21 |   const mockConnection = {} as WebApi;
 22 | 
 23 |   describe('isPipelinesRequest', () => {
 24 |     it('should return true for pipelines requests', () => {
 25 |       const validTools = [
 26 |         'list_pipelines',
 27 |         'get_pipeline',
 28 |         'list_pipeline_runs',
 29 |         'get_pipeline_run',
 30 |         'pipeline_timeline',
 31 |         'get_pipeline_log',
 32 |         'trigger_pipeline',
 33 |       ];
 34 |       validTools.forEach((tool) => {
 35 |         const request = {
 36 |           params: { name: tool, arguments: {} },
 37 |           method: 'tools/call',
 38 |         } as CallToolRequest;
 39 |         expect(isPipelinesRequest(request)).toBe(true);
 40 |       });
 41 |     });
 42 | 
 43 |     it('should return false for non-pipelines requests', () => {
 44 |       const request = {
 45 |         params: { name: 'get_project', arguments: {} },
 46 |         method: 'tools/call',
 47 |       } as CallToolRequest;
 48 |       expect(isPipelinesRequest(request)).toBe(false);
 49 |     });
 50 |   });
 51 | 
 52 |   describe('handlePipelinesRequest', () => {
 53 |     it('should handle list_pipelines request', async () => {
 54 |       const mockPipelines = [
 55 |         { id: 1, name: 'Pipeline 1' },
 56 |         { id: 2, name: 'Pipeline 2' },
 57 |       ];
 58 | 
 59 |       (listPipelines as jest.Mock).mockResolvedValue(mockPipelines);
 60 | 
 61 |       const request = {
 62 |         params: {
 63 |           name: 'list_pipelines',
 64 |           arguments: {
 65 |             projectId: 'test-project',
 66 |           },
 67 |         },
 68 |         method: 'tools/call',
 69 |       } as CallToolRequest;
 70 | 
 71 |       const response = await handlePipelinesRequest(mockConnection, request);
 72 |       expect(response.content).toHaveLength(1);
 73 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
 74 |         mockPipelines,
 75 |       );
 76 |       expect(listPipelines).toHaveBeenCalledWith(
 77 |         mockConnection,
 78 |         expect.objectContaining({
 79 |           projectId: 'test-project',
 80 |         }),
 81 |       );
 82 |     });
 83 | 
 84 |     it('should handle get_pipeline request', async () => {
 85 |       const mockPipeline = { id: 1, name: 'Pipeline 1' };
 86 |       (getPipeline as jest.Mock).mockResolvedValue(mockPipeline);
 87 | 
 88 |       const request = {
 89 |         params: {
 90 |           name: 'get_pipeline',
 91 |           arguments: {
 92 |             projectId: 'test-project',
 93 |             pipelineId: 1,
 94 |           },
 95 |         },
 96 |         method: 'tools/call',
 97 |       } as CallToolRequest;
 98 | 
 99 |       const response = await handlePipelinesRequest(mockConnection, request);
100 |       expect(response.content).toHaveLength(1);
101 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
102 |         mockPipeline,
103 |       );
104 |       expect(getPipeline).toHaveBeenCalledWith(
105 |         mockConnection,
106 |         expect.objectContaining({
107 |           projectId: 'test-project',
108 |           pipelineId: 1,
109 |         }),
110 |       );
111 |     });
112 | 
113 |     it('should handle trigger_pipeline request', async () => {
114 |       const mockRun = { id: 1, state: 'inProgress' };
115 |       (triggerPipeline as jest.Mock).mockResolvedValue(mockRun);
116 | 
117 |       const request = {
118 |         params: {
119 |           name: 'trigger_pipeline',
120 |           arguments: {
121 |             projectId: 'test-project',
122 |             pipelineId: 1,
123 |           },
124 |         },
125 |         method: 'tools/call',
126 |       } as CallToolRequest;
127 | 
128 |       const response = await handlePipelinesRequest(mockConnection, request);
129 |       expect(response.content).toHaveLength(1);
130 |       expect(JSON.parse(response.content[0].text as string)).toEqual(mockRun);
131 |       expect(triggerPipeline).toHaveBeenCalledWith(
132 |         mockConnection,
133 |         expect.objectContaining({
134 |           projectId: 'test-project',
135 |           pipelineId: 1,
136 |         }),
137 |       );
138 |     });
139 | 
140 |     it('should handle list_pipeline_runs request', async () => {
141 |       const mockRuns = { runs: [{ id: 1 }], continuationToken: 'next' };
142 |       (listPipelineRuns as jest.Mock).mockResolvedValue(mockRuns);
143 | 
144 |       const request = {
145 |         params: {
146 |           name: 'list_pipeline_runs',
147 |           arguments: {
148 |             projectId: 'test-project',
149 |             pipelineId: 99,
150 |           },
151 |         },
152 |         method: 'tools/call',
153 |       } as CallToolRequest;
154 | 
155 |       const response = await handlePipelinesRequest(mockConnection, request);
156 |       expect(JSON.parse(response.content[0].text as string)).toEqual(mockRuns);
157 |       expect(listPipelineRuns).toHaveBeenCalledWith(
158 |         mockConnection,
159 |         expect.objectContaining({
160 |           projectId: 'test-project',
161 |           pipelineId: 99,
162 |         }),
163 |       );
164 |     });
165 | 
166 |     it('should handle get_pipeline_run request', async () => {
167 |       const mockRun = { id: 123 };
168 |       (getPipelineRun as jest.Mock).mockResolvedValue(mockRun);
169 | 
170 |       const request = {
171 |         params: {
172 |           name: 'get_pipeline_run',
173 |           arguments: {
174 |             projectId: 'test-project',
175 |             runId: 123,
176 |           },
177 |         },
178 |         method: 'tools/call',
179 |       } as CallToolRequest;
180 | 
181 |       const response = await handlePipelinesRequest(mockConnection, request);
182 |       expect(JSON.parse(response.content[0].text as string)).toEqual(mockRun);
183 |       expect(getPipelineRun).toHaveBeenCalledWith(
184 |         mockConnection,
185 |         expect.objectContaining({
186 |           projectId: 'test-project',
187 |           runId: 123,
188 |         }),
189 |       );
190 |     });
191 | 
192 |     it('should handle pipeline_timeline request', async () => {
193 |       const mockTimeline = { records: [{ name: 'Stage 1' }] };
194 |       (getPipelineTimeline as jest.Mock).mockResolvedValue(mockTimeline);
195 | 
196 |       const request = {
197 |         params: {
198 |           name: 'pipeline_timeline',
199 |           arguments: {
200 |             projectId: 'test-project',
201 |             pipelineId: 99,
202 |             runId: 321,
203 |           },
204 |         },
205 |         method: 'tools/call',
206 |       } as CallToolRequest;
207 | 
208 |       const response = await handlePipelinesRequest(mockConnection, request);
209 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
210 |         mockTimeline,
211 |       );
212 |       expect(getPipelineTimeline).toHaveBeenCalledWith(
213 |         mockConnection,
214 |         expect.objectContaining({
215 |           projectId: 'test-project',
216 |           pipelineId: 99,
217 |           runId: 321,
218 |         }),
219 |       );
220 |     });
221 | 
222 |     it('should handle get_pipeline_log request', async () => {
223 |       (getPipelineLog as jest.Mock).mockResolvedValue('log lines');
224 | 
225 |       const request = {
226 |         params: {
227 |           name: 'get_pipeline_log',
228 |           arguments: {
229 |             projectId: 'test-project',
230 |             pipelineId: 99,
231 |             runId: 321,
232 |             logId: 7,
233 |           },
234 |         },
235 |         method: 'tools/call',
236 |       } as CallToolRequest;
237 | 
238 |       const response = await handlePipelinesRequest(mockConnection, request);
239 |       expect(response.content[0].text).toBe('log lines');
240 |       expect(getPipelineLog).toHaveBeenCalledWith(
241 |         mockConnection,
242 |         expect.objectContaining({
243 |           projectId: 'test-project',
244 |           pipelineId: 99,
245 |           runId: 321,
246 |           logId: 7,
247 |         }),
248 |       );
249 |     });
250 | 
251 |     it('should throw error for unknown tool', async () => {
252 |       const request = {
253 |         params: {
254 |           name: 'unknown_tool',
255 |           arguments: {},
256 |         },
257 |         method: 'tools/call',
258 |       } as CallToolRequest;
259 | 
260 |       await expect(
261 |         handlePipelinesRequest(mockConnection, request),
262 |       ).rejects.toThrow('Unknown pipelines tool');
263 |     });
264 | 
265 |     it('should propagate errors from pipeline functions', async () => {
266 |       const mockError = new Error('Test error');
267 |       (listPipelines as jest.Mock).mockRejectedValue(mockError);
268 | 
269 |       const request = {
270 |         params: {
271 |           name: 'list_pipelines',
272 |           arguments: {
273 |             projectId: 'test-project',
274 |           },
275 |         },
276 |         method: 'tools/call',
277 |       } as CallToolRequest;
278 | 
279 |       await expect(
280 |         handlePipelinesRequest(mockConnection, request),
281 |       ).rejects.toThrow(mockError);
282 |     });
283 |   });
284 | });
285 | 
```

--------------------------------------------------------------------------------
/src/server.spec.e2e.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js';
  2 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
  3 | import { spawn } from 'child_process';
  4 | import { join } from 'path';
  5 | import dotenv from 'dotenv';
  6 | import { Organization } from './features/organizations/types';
  7 | import fs from 'fs';
  8 | 
  9 | // Load environment variables from .env file
 10 | dotenv.config();
 11 | 
 12 | describe('Azure DevOps MCP Server E2E Tests', () => {
 13 |   let client: Client;
 14 |   let serverProcess: ReturnType<typeof spawn>;
 15 |   let transport: StdioClientTransport;
 16 |   let tempEnvFile: string | null = null;
 17 | 
 18 |   beforeAll(async () => {
 19 |     // Debug: Log environment variables
 20 |     console.error('E2E TEST ENVIRONMENT VARIABLES:');
 21 |     console.error(
 22 |       `AZURE_DEVOPS_ORG_URL: ${process.env.AZURE_DEVOPS_ORG_URL || 'NOT SET'}`,
 23 |     );
 24 |     console.error(
 25 |       `AZURE_DEVOPS_PAT: ${process.env.AZURE_DEVOPS_PAT ? 'SET (hidden value)' : 'NOT SET'}`,
 26 |     );
 27 |     console.error(
 28 |       `AZURE_DEVOPS_DEFAULT_PROJECT: ${process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'NOT SET'}`,
 29 |     );
 30 |     console.error(
 31 |       `AZURE_DEVOPS_AUTH_METHOD: ${process.env.AZURE_DEVOPS_AUTH_METHOD || 'NOT SET'}`,
 32 |     );
 33 | 
 34 |     // Start the MCP server process
 35 |     const serverPath = join(process.cwd(), 'dist', 'index.js');
 36 | 
 37 |     // Create a temporary .env file for testing if needed
 38 |     const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || '';
 39 |     const pat = process.env.AZURE_DEVOPS_PAT || '';
 40 |     const defaultProject = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '';
 41 |     const authMethod = process.env.AZURE_DEVOPS_AUTH_METHOD || 'pat';
 42 | 
 43 |     if (orgUrl) {
 44 |       // Create a temporary .env file for the test
 45 |       tempEnvFile = join(process.cwd(), '.env.e2e-test');
 46 | 
 47 |       const envFileContent = `
 48 | AZURE_DEVOPS_ORG_URL=${orgUrl}
 49 | AZURE_DEVOPS_PAT=${pat}
 50 | AZURE_DEVOPS_DEFAULT_PROJECT=${defaultProject}
 51 | AZURE_DEVOPS_AUTH_METHOD=${authMethod}
 52 | `;
 53 | 
 54 |       fs.writeFileSync(tempEnvFile, envFileContent);
 55 |       console.error(`Created temporary .env file at ${tempEnvFile}`);
 56 | 
 57 |       // Start server with explicit file path to the temp .env file
 58 |       serverProcess = spawn('node', ['-r', 'dotenv/config', serverPath], {
 59 |         env: {
 60 |           ...process.env,
 61 |           NODE_ENV: 'test',
 62 |           DOTENV_CONFIG_PATH: tempEnvFile,
 63 |         },
 64 |       });
 65 |     } else {
 66 |       throw new Error(
 67 |         'Cannot start server: AZURE_DEVOPS_ORG_URL is not set in the environment',
 68 |       );
 69 |     }
 70 | 
 71 |     // Capture server output for debugging
 72 |     if (serverProcess && serverProcess.stderr) {
 73 |       serverProcess.stderr.on('data', (data) => {
 74 |         console.error(`Server error: ${data.toString()}`);
 75 |       });
 76 |     }
 77 | 
 78 |     // Give the server a moment to start
 79 |     await new Promise((resolve) => setTimeout(resolve, 1000));
 80 | 
 81 |     // Connect the MCP client to the server
 82 |     transport = new StdioClientTransport({
 83 |       command: 'node',
 84 |       args: ['-r', 'dotenv/config', serverPath],
 85 |       env: {
 86 |         ...process.env,
 87 |         NODE_ENV: 'test',
 88 |         DOTENV_CONFIG_PATH: tempEnvFile,
 89 |       },
 90 |     });
 91 | 
 92 |     client = new Client(
 93 |       {
 94 |         name: 'e2e-test-client',
 95 |         version: '1.0.0',
 96 |       },
 97 |       {
 98 |         capabilities: {
 99 |           tools: {},
100 |         },
101 |       },
102 |     );
103 | 
104 |     await client.connect(transport);
105 |   });
106 | 
107 |   afterAll(async () => {
108 |     // Clean up the client transport
109 |     if (transport) {
110 |       await transport.close();
111 |     }
112 | 
113 |     // Clean up the client
114 |     if (client) {
115 |       await client.close();
116 |     }
117 | 
118 |     // Clean up the server process
119 |     if (serverProcess) {
120 |       serverProcess.kill();
121 |     }
122 | 
123 |     // Clean up temporary env file
124 |     if (tempEnvFile && fs.existsSync(tempEnvFile)) {
125 |       fs.unlinkSync(tempEnvFile);
126 |       console.error(`Deleted temporary .env file at ${tempEnvFile}`);
127 |     }
128 | 
129 |     // Force exit to clean up any remaining handles
130 |     await new Promise<void>((resolve) => {
131 |       setTimeout(() => {
132 |         resolve();
133 |       }, 500);
134 |     });
135 |   });
136 | 
137 |   describe('Organizations', () => {
138 |     test('should list organizations', async () => {
139 |       // Arrange
140 |       // No specific arrangement needed for this test as we're just listing organizations
141 | 
142 |       // Act
143 |       const result = await client.callTool({
144 |         name: 'list_organizations',
145 |         arguments: {},
146 |       });
147 | 
148 |       // Assert
149 |       expect(result).toBeDefined();
150 | 
151 |       // Access the content safely
152 |       const content = result.content as Array<{ type: string; text: string }>;
153 |       expect(content).toBeDefined();
154 |       expect(content.length).toBeGreaterThan(0);
155 | 
156 |       // Parse the result content
157 |       const resultText = content[0].text;
158 |       const organizations: Organization[] = JSON.parse(resultText);
159 | 
160 |       // Verify the response structure
161 |       expect(Array.isArray(organizations)).toBe(true);
162 |       if (organizations.length > 0) {
163 |         const firstOrg = organizations[0];
164 |         expect(firstOrg).toHaveProperty('id');
165 |         expect(firstOrg).toHaveProperty('name');
166 |         expect(firstOrg).toHaveProperty('url');
167 |       }
168 |     });
169 |   });
170 | 
171 |   describe('Parameterless Tools', () => {
172 |     test('should call list_organizations without arguments', async () => {
173 |       // Act - call the tool without providing arguments
174 |       const result = await client.callTool({
175 |         name: 'list_organizations',
176 |         // No arguments provided
177 |         arguments: {},
178 |       });
179 | 
180 |       // Assert
181 |       expect(result).toBeDefined();
182 |       const content = result.content as Array<{ type: string; text: string }>;
183 |       expect(content).toBeDefined();
184 |       expect(content.length).toBeGreaterThan(0);
185 | 
186 |       // Verify we got a valid JSON response
187 |       const resultText = content[0].text;
188 |       const organizations = JSON.parse(resultText);
189 |       expect(Array.isArray(organizations)).toBe(true);
190 |     });
191 | 
192 |     test('should call get_me without arguments', async () => {
193 |       // Act - call the tool without providing arguments
194 |       const result = await client.callTool({
195 |         name: 'get_me',
196 |         // No arguments provided
197 |         arguments: {},
198 |       });
199 | 
200 |       // Assert
201 |       expect(result).toBeDefined();
202 |       const content = result.content as Array<{ type: string; text: string }>;
203 |       expect(content).toBeDefined();
204 |       expect(content.length).toBeGreaterThan(0);
205 | 
206 |       // Verify we got a valid JSON response with user info
207 |       const resultText = content[0].text;
208 |       const userInfo = JSON.parse(resultText);
209 |       expect(userInfo).toHaveProperty('id');
210 |       expect(userInfo).toHaveProperty('displayName');
211 |     });
212 |   });
213 | 
214 |   describe('Tools with Optional Parameters', () => {
215 |     test('should call list_projects without arguments', async () => {
216 |       // Act - call the tool without providing arguments
217 |       const result = await client.callTool({
218 |         name: 'list_projects',
219 |         // No arguments provided
220 |         arguments: {},
221 |       });
222 | 
223 |       // Assert
224 |       expect(result).toBeDefined();
225 |       const content = result.content as Array<{ type: string; text: string }>;
226 |       expect(content).toBeDefined();
227 |       expect(content.length).toBeGreaterThan(0);
228 | 
229 |       // Verify we got a valid JSON response
230 |       const resultText = content[0].text;
231 |       const projects = JSON.parse(resultText);
232 |       expect(Array.isArray(projects)).toBe(true);
233 |     });
234 | 
235 |     test('should call get_project without arguments', async () => {
236 |       // Act - call the tool without providing arguments
237 |       const result = await client.callTool({
238 |         name: 'get_project',
239 |         // No arguments provided
240 |         arguments: {},
241 |       });
242 | 
243 |       // Assert
244 |       expect(result).toBeDefined();
245 |       const content = result.content as Array<{ type: string; text: string }>;
246 |       expect(content).toBeDefined();
247 |       expect(content.length).toBeGreaterThan(0);
248 | 
249 |       // Verify we got a valid JSON response with project info
250 |       const resultText = content[0].text;
251 |       const project = JSON.parse(resultText);
252 |       expect(project).toHaveProperty('id');
253 |       expect(project).toHaveProperty('name');
254 |     });
255 | 
256 |     test('should call list_repositories without arguments', async () => {
257 |       // Act - call the tool without providing arguments
258 |       const result = await client.callTool({
259 |         name: 'list_repositories',
260 |         // No arguments provided
261 |         arguments: {},
262 |       });
263 | 
264 |       // Assert
265 |       expect(result).toBeDefined();
266 |       const content = result.content as Array<{ type: string; text: string }>;
267 |       expect(content).toBeDefined();
268 |       expect(content.length).toBeGreaterThan(0);
269 | 
270 |       // Verify we got a valid JSON response
271 |       const resultText = content[0].text;
272 |       const repositories = JSON.parse(resultText);
273 |       expect(Array.isArray(repositories)).toBe(true);
274 |     });
275 |   });
276 | });
277 | 
```

--------------------------------------------------------------------------------
/src/features/pull-requests/schemas.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { z } from 'zod';
  2 | import { defaultProject, defaultOrg } from '../../utils/environment';
  3 | 
  4 | /**
  5 |  * Schema for creating a pull request
  6 |  */
  7 | export const CreatePullRequestSchema = 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 |   title: z.string().describe('The title of the pull request'),
 18 |   description: z
 19 |     .string()
 20 |     .optional()
 21 |     .describe('The description of the pull request (markdown is supported)'),
 22 |   sourceRefName: z
 23 |     .string()
 24 |     .describe('The source branch name (e.g., refs/heads/feature-branch)'),
 25 |   targetRefName: z
 26 |     .string()
 27 |     .describe('The target branch name (e.g., refs/heads/main)'),
 28 |   reviewers: z
 29 |     .array(z.string())
 30 |     .optional()
 31 |     .describe('List of reviewer email addresses or IDs'),
 32 |   isDraft: z
 33 |     .boolean()
 34 |     .optional()
 35 |     .describe('Whether the pull request should be created as a draft'),
 36 |   workItemRefs: z
 37 |     .array(z.number())
 38 |     .optional()
 39 |     .describe('List of work item IDs to link to the pull request'),
 40 |   tags: z
 41 |     .array(z.string().trim().min(1))
 42 |     .optional()
 43 |     .describe('List of tags to apply to the pull request'),
 44 |   additionalProperties: z
 45 |     .record(z.string(), z.any())
 46 |     .optional()
 47 |     .describe('Additional properties to set on the pull request'),
 48 | });
 49 | 
 50 | /**
 51 |  * Schema for listing pull requests
 52 |  */
 53 | export const ListPullRequestsSchema = z.object({
 54 |   projectId: z
 55 |     .string()
 56 |     .optional()
 57 |     .describe(`The ID or name of the project (Default: ${defaultProject})`),
 58 |   organizationId: z
 59 |     .string()
 60 |     .optional()
 61 |     .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
 62 |   repositoryId: z.string().describe('The ID or name of the repository'),
 63 |   status: z
 64 |     .enum(['all', 'active', 'completed', 'abandoned'])
 65 |     .optional()
 66 |     .describe('Filter by pull request status'),
 67 |   creatorId: z
 68 |     .string()
 69 |     .optional()
 70 |     .describe('Filter by creator ID (must be a UUID string)'),
 71 |   reviewerId: z
 72 |     .string()
 73 |     .optional()
 74 |     .describe('Filter by reviewer ID (must be a UUID string)'),
 75 |   sourceRefName: z.string().optional().describe('Filter by source branch name'),
 76 |   targetRefName: z.string().optional().describe('Filter by target branch name'),
 77 |   top: z
 78 |     .number()
 79 |     .default(10)
 80 |     .describe('Maximum number of pull requests to return (default: 10)'),
 81 |   skip: z
 82 |     .number()
 83 |     .optional()
 84 |     .describe('Number of pull requests to skip for pagination'),
 85 |   pullRequestId: z
 86 |     .number()
 87 |     .optional()
 88 |     .describe('If provided, return only the matching pull request ID'),
 89 | });
 90 | 
 91 | /**
 92 |  * Schema for getting pull request comments
 93 |  */
 94 | export const GetPullRequestCommentsSchema = z.object({
 95 |   projectId: z
 96 |     .string()
 97 |     .optional()
 98 |     .describe(`The ID or name of the project (Default: ${defaultProject})`),
 99 |   organizationId: z
100 |     .string()
101 |     .optional()
102 |     .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
103 |   repositoryId: z.string().describe('The ID or name of the repository'),
104 |   pullRequestId: z.number().describe('The ID of the pull request'),
105 |   threadId: z
106 |     .number()
107 |     .optional()
108 |     .describe('The ID of the specific thread to get comments from'),
109 |   includeDeleted: z
110 |     .boolean()
111 |     .optional()
112 |     .describe('Whether to include deleted comments'),
113 |   top: z
114 |     .number()
115 |     .optional()
116 |     .describe('Maximum number of threads/comments to return'),
117 | });
118 | 
119 | /**
120 |  * Schema for adding a comment to a pull request
121 |  */
122 | export const AddPullRequestCommentSchema = z
123 |   .object({
124 |     projectId: z
125 |       .string()
126 |       .optional()
127 |       .describe(`The ID or name of the project (Default: ${defaultProject})`),
128 |     organizationId: z
129 |       .string()
130 |       .optional()
131 |       .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
132 |     repositoryId: z.string().describe('The ID or name of the repository'),
133 |     pullRequestId: z.number().describe('The ID of the pull request'),
134 |     content: z.string().describe('The content of the comment in markdown'),
135 |     threadId: z
136 |       .number()
137 |       .optional()
138 |       .describe('The ID of the thread to add the comment to'),
139 |     parentCommentId: z
140 |       .number()
141 |       .optional()
142 |       .describe(
143 |         'ID of the parent comment when replying to an existing comment',
144 |       ),
145 |     filePath: z
146 |       .string()
147 |       .optional()
148 |       .describe('The path of the file to comment on (for new thread on file)'),
149 |     lineNumber: z
150 |       .number()
151 |       .optional()
152 |       .describe('The line number to comment on (for new thread on file)'),
153 |     status: z
154 |       .enum([
155 |         'active',
156 |         'fixed',
157 |         'wontFix',
158 |         'closed',
159 |         'pending',
160 |         'byDesign',
161 |         'unknown',
162 |       ])
163 |       .optional()
164 |       .describe('The status to set for a new thread'),
165 |   })
166 |   .superRefine((data, ctx) => {
167 |     // If we're creating a new thread (no threadId), status is required
168 |     if (!data.threadId && !data.status) {
169 |       ctx.addIssue({
170 |         code: z.ZodIssueCode.custom,
171 |         message: 'Status is required when creating a new thread',
172 |         path: ['status'],
173 |       });
174 |     }
175 |   });
176 | 
177 | /**
178 |  * Schema for updating a pull request
179 |  */
180 | export const UpdatePullRequestSchema = z.object({
181 |   projectId: z
182 |     .string()
183 |     .optional()
184 |     .describe(`The ID or name of the project (Default: ${defaultProject})`),
185 |   organizationId: z
186 |     .string()
187 |     .optional()
188 |     .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
189 |   repositoryId: z.string().describe('The ID or name of the repository'),
190 |   pullRequestId: z.number().describe('The ID of the pull request to update'),
191 |   title: z
192 |     .string()
193 |     .optional()
194 |     .describe('The updated title of the pull request'),
195 |   description: z
196 |     .string()
197 |     .optional()
198 |     .describe('The updated description of the pull request'),
199 |   status: z
200 |     .enum(['active', 'abandoned', 'completed'])
201 |     .optional()
202 |     .describe('The updated status of the pull request'),
203 |   isDraft: z
204 |     .boolean()
205 |     .optional()
206 |     .describe(
207 |       'Whether the pull request should be marked as a draft (true) or unmarked (false)',
208 |     ),
209 |   addWorkItemIds: z
210 |     .array(z.number())
211 |     .optional()
212 |     .describe('List of work item IDs to link to the pull request'),
213 |   removeWorkItemIds: z
214 |     .array(z.number())
215 |     .optional()
216 |     .describe('List of work item IDs to unlink from the pull request'),
217 |   addReviewers: z
218 |     .array(z.string())
219 |     .optional()
220 |     .describe('List of reviewer email addresses or IDs to add'),
221 |   removeReviewers: z
222 |     .array(z.string())
223 |     .optional()
224 |     .describe('List of reviewer email addresses or IDs to remove'),
225 |   addTags: z
226 |     .array(z.string().trim().min(1))
227 |     .optional()
228 |     .describe('List of tags to add to the pull request'),
229 |   removeTags: z
230 |     .array(z.string().trim().min(1))
231 |     .optional()
232 |     .describe('List of tags to remove from the pull request'),
233 |   additionalProperties: z
234 |     .record(z.string(), z.any())
235 |     .optional()
236 |     .describe('Additional properties to update on the pull request'),
237 | });
238 | 
239 | /**
240 |  * Schema for getting pull request changes and policy evaluations
241 |  */
242 | export const GetPullRequestChangesSchema = z.object({
243 |   projectId: z
244 |     .string()
245 |     .optional()
246 |     .describe(`The ID or name of the project (Default: ${defaultProject})`),
247 |   organizationId: z
248 |     .string()
249 |     .optional()
250 |     .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
251 |   repositoryId: z.string().describe('The ID or name of the repository'),
252 |   pullRequestId: z.number().describe('The ID of the pull request'),
253 | });
254 | 
255 | /**
256 |  * Schema for retrieving pull request status checks and policy evaluations
257 |  */
258 | export const GetPullRequestChecksSchema = z.object({
259 |   projectId: z
260 |     .string()
261 |     .optional()
262 |     .describe(`The ID or name of the project (Default: ${defaultProject})`),
263 |   organizationId: z
264 |     .string()
265 |     .optional()
266 |     .describe(`The ID or name of the organization (Default: ${defaultOrg})`),
267 |   repositoryId: z.string().describe('The ID or name of the repository'),
268 |   pullRequestId: z.number().describe('The ID of the pull request'),
269 | });
270 | 
271 | export const PullRequestFileChangeSchema = z.object({
272 |   path: z.string().describe('Path of the changed file'),
273 |   patch: z.string().describe('Unified diff of the file'),
274 | });
275 | 
276 | export const GetPullRequestChangesResponseSchema = z.object({
277 |   changes: z.any(),
278 |   evaluations: z.array(z.any()),
279 |   files: z.array(PullRequestFileChangeSchema),
280 |   sourceRefName: z.string().optional(),
281 |   targetRefName: z.string().optional(),
282 | });
283 | 
```

--------------------------------------------------------------------------------
/src/features/repositories/get-repository-details/feature.spec.unit.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { getRepositoryDetails } from './feature';
  2 | import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces';
  3 | import {
  4 |   AzureDevOpsError,
  5 |   AzureDevOpsResourceNotFoundError,
  6 | } from '../../../shared/errors';
  7 | import { GitRepository, GitBranchStats, GitRef } from '../types';
  8 | 
  9 | // Unit tests should only focus on isolated logic
 10 | // No real connections, HTTP requests, or dependencies
 11 | describe('getRepositoryDetails unit', () => {
 12 |   // Mock repository data
 13 |   const mockRepository: GitRepository = {
 14 |     id: 'repo-id',
 15 |     name: 'test-repo',
 16 |     url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id',
 17 |     project: {
 18 |       id: 'project-id',
 19 |       name: 'test-project',
 20 |     },
 21 |     defaultBranch: 'refs/heads/main',
 22 |     size: 1024,
 23 |     remoteUrl: 'https://dev.azure.com/org/project/_git/test-repo',
 24 |     sshUrl: '[email protected]:v3/org/project/test-repo',
 25 |     webUrl: 'https://dev.azure.com/org/project/_git/test-repo',
 26 |   };
 27 | 
 28 |   // Mock branch stats data
 29 |   const mockBranchStats: GitBranchStats[] = [
 30 |     {
 31 |       name: 'refs/heads/main',
 32 |       aheadCount: 0,
 33 |       behindCount: 0,
 34 |       isBaseVersion: true,
 35 |       commit: {
 36 |         commitId: 'commit-id',
 37 |         author: {
 38 |           name: 'Test User',
 39 |           email: '[email protected]',
 40 |           date: new Date(),
 41 |         },
 42 |         committer: {
 43 |           name: 'Test User',
 44 |           email: '[email protected]',
 45 |           date: new Date(),
 46 |         },
 47 |         comment: 'Test commit',
 48 |       },
 49 |     },
 50 |   ];
 51 | 
 52 |   // Mock refs data
 53 |   const mockRefs: GitRef[] = [
 54 |     {
 55 |       name: 'refs/heads/main',
 56 |       objectId: 'commit-id',
 57 |       creator: {
 58 |         displayName: 'Test User',
 59 |         id: 'user-id',
 60 |       },
 61 |       url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo-id/refs/heads/main',
 62 |     },
 63 |   ];
 64 | 
 65 |   test('should return basic repository information when no additional options are specified', async () => {
 66 |     // Arrange
 67 |     const mockConnection: any = {
 68 |       getGitApi: jest.fn().mockImplementation(() => ({
 69 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
 70 |       })),
 71 |     };
 72 | 
 73 |     // Act
 74 |     const result = await getRepositoryDetails(mockConnection, {
 75 |       projectId: 'test-project',
 76 |       repositoryId: 'test-repo',
 77 |     });
 78 | 
 79 |     // Assert
 80 |     expect(result).toBeDefined();
 81 |     expect(result.repository).toEqual(mockRepository);
 82 |     expect(result.statistics).toBeUndefined();
 83 |     expect(result.refs).toBeUndefined();
 84 |   });
 85 | 
 86 |   test('should include branch statistics when includeStatistics is true', async () => {
 87 |     // Arrange
 88 |     const mockConnection: any = {
 89 |       getGitApi: jest.fn().mockImplementation(() => ({
 90 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
 91 |         getBranches: jest.fn().mockResolvedValue(mockBranchStats),
 92 |       })),
 93 |     };
 94 | 
 95 |     // Act
 96 |     const result = await getRepositoryDetails(mockConnection, {
 97 |       projectId: 'test-project',
 98 |       repositoryId: 'test-repo',
 99 |       includeStatistics: true,
100 |     });
101 | 
102 |     // Assert
103 |     expect(result).toBeDefined();
104 |     expect(result.repository).toEqual(mockRepository);
105 |     expect(result.statistics).toBeDefined();
106 |     expect(result.statistics?.branches).toEqual(mockBranchStats);
107 |     expect(result.refs).toBeUndefined();
108 |   });
109 | 
110 |   test('should include refs when includeRefs is true', async () => {
111 |     // Arrange
112 |     const mockConnection: any = {
113 |       getGitApi: jest.fn().mockImplementation(() => ({
114 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
115 |         getRefs: jest.fn().mockResolvedValue(mockRefs),
116 |       })),
117 |     };
118 | 
119 |     // Act
120 |     const result = await getRepositoryDetails(mockConnection, {
121 |       projectId: 'test-project',
122 |       repositoryId: 'test-repo',
123 |       includeRefs: true,
124 |     });
125 | 
126 |     // Assert
127 |     expect(result).toBeDefined();
128 |     expect(result.repository).toEqual(mockRepository);
129 |     expect(result.statistics).toBeUndefined();
130 |     expect(result.refs).toBeDefined();
131 |     expect(result.refs?.value).toEqual(mockRefs);
132 |     expect(result.refs?.count).toBe(mockRefs.length);
133 |   });
134 | 
135 |   test('should include both statistics and refs when both options are true', async () => {
136 |     // Arrange
137 |     const mockConnection: any = {
138 |       getGitApi: jest.fn().mockImplementation(() => ({
139 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
140 |         getBranches: jest.fn().mockResolvedValue(mockBranchStats),
141 |         getRefs: jest.fn().mockResolvedValue(mockRefs),
142 |       })),
143 |     };
144 | 
145 |     // Act
146 |     const result = await getRepositoryDetails(mockConnection, {
147 |       projectId: 'test-project',
148 |       repositoryId: 'test-repo',
149 |       includeStatistics: true,
150 |       includeRefs: true,
151 |     });
152 | 
153 |     // Assert
154 |     expect(result).toBeDefined();
155 |     expect(result.repository).toEqual(mockRepository);
156 |     expect(result.statistics).toBeDefined();
157 |     expect(result.statistics?.branches).toEqual(mockBranchStats);
158 |     expect(result.refs).toBeDefined();
159 |     expect(result.refs?.value).toEqual(mockRefs);
160 |     expect(result.refs?.count).toBe(mockRefs.length);
161 |   });
162 | 
163 |   test('should pass refFilter to getRefs when provided', async () => {
164 |     // Arrange
165 |     const getRefs = jest.fn().mockResolvedValue(mockRefs);
166 |     const mockConnection: any = {
167 |       getGitApi: jest.fn().mockImplementation(() => ({
168 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
169 |         getRefs,
170 |       })),
171 |     };
172 | 
173 |     // Act
174 |     await getRepositoryDetails(mockConnection, {
175 |       projectId: 'test-project',
176 |       repositoryId: 'test-repo',
177 |       includeRefs: true,
178 |       refFilter: 'heads/',
179 |     });
180 | 
181 |     // Assert
182 |     expect(getRefs).toHaveBeenCalledWith(
183 |       mockRepository.id,
184 |       'test-project',
185 |       'heads/',
186 |     );
187 |   });
188 | 
189 |   test('should pass branchName to getBranches when provided', async () => {
190 |     // Arrange
191 |     const getBranches = jest.fn().mockResolvedValue(mockBranchStats);
192 |     const mockConnection: any = {
193 |       getGitApi: jest.fn().mockImplementation(() => ({
194 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
195 |         getBranches,
196 |       })),
197 |     };
198 | 
199 |     // Act
200 |     await getRepositoryDetails(mockConnection, {
201 |       projectId: 'test-project',
202 |       repositoryId: 'test-repo',
203 |       includeStatistics: true,
204 |       branchName: 'main',
205 |     });
206 | 
207 |     // Assert
208 |     expect(getBranches).toHaveBeenCalledWith(
209 |       mockRepository.id,
210 |       'test-project',
211 |       {
212 |         version: 'main',
213 |         versionType: GitVersionType.Branch,
214 |       },
215 |     );
216 |   });
217 | 
218 |   test('should propagate resource not found errors', async () => {
219 |     // Arrange
220 |     const mockConnection: any = {
221 |       getGitApi: jest.fn().mockImplementation(() => ({
222 |         getRepository: jest.fn().mockResolvedValue(null), // Simulate repository not found
223 |       })),
224 |     };
225 | 
226 |     // Act & Assert
227 |     await expect(
228 |       getRepositoryDetails(mockConnection, {
229 |         projectId: 'test-project',
230 |         repositoryId: 'non-existent-repo',
231 |       }),
232 |     ).rejects.toThrow(AzureDevOpsResourceNotFoundError);
233 | 
234 |     await expect(
235 |       getRepositoryDetails(mockConnection, {
236 |         projectId: 'test-project',
237 |         repositoryId: 'non-existent-repo',
238 |       }),
239 |     ).rejects.toThrow(
240 |       "Repository 'non-existent-repo' not found in project 'test-project'",
241 |     );
242 |   });
243 | 
244 |   test('should propagate custom errors when thrown internally', async () => {
245 |     // Arrange
246 |     const mockConnection: any = {
247 |       getGitApi: jest.fn().mockImplementation(() => {
248 |         throw new AzureDevOpsError('Custom error');
249 |       }),
250 |     };
251 | 
252 |     // Act & Assert
253 |     await expect(
254 |       getRepositoryDetails(mockConnection, {
255 |         projectId: 'test-project',
256 |         repositoryId: 'test-repo',
257 |       }),
258 |     ).rejects.toThrow(AzureDevOpsError);
259 | 
260 |     await expect(
261 |       getRepositoryDetails(mockConnection, {
262 |         projectId: 'test-project',
263 |         repositoryId: 'test-repo',
264 |       }),
265 |     ).rejects.toThrow('Custom error');
266 |   });
267 | 
268 |   test('should wrap unexpected errors in a friendly error message', async () => {
269 |     // Arrange
270 |     const mockConnection: any = {
271 |       getGitApi: jest.fn().mockImplementation(() => {
272 |         throw new Error('Unexpected error');
273 |       }),
274 |     };
275 | 
276 |     // Act & Assert
277 |     await expect(
278 |       getRepositoryDetails(mockConnection, {
279 |         projectId: 'test-project',
280 |         repositoryId: 'test-repo',
281 |       }),
282 |     ).rejects.toThrow('Failed to get repository details: Unexpected error');
283 |   });
284 | 
285 |   test('should handle null refs gracefully', async () => {
286 |     // Arrange
287 |     const mockConnection: any = {
288 |       getGitApi: jest.fn().mockImplementation(() => ({
289 |         getRepository: jest.fn().mockResolvedValue(mockRepository),
290 |         getRefs: jest.fn().mockResolvedValue(null), // Simulate null refs
291 |       })),
292 |     };
293 | 
294 |     // Act
295 |     const result = await getRepositoryDetails(mockConnection, {
296 |       projectId: 'test-project',
297 |       repositoryId: 'test-repo',
298 |       includeRefs: true,
299 |     });
300 | 
301 |     // Assert
302 |     expect(result).toBeDefined();
303 |     expect(result.repository).toEqual(mockRepository);
304 |     expect(result.refs).toBeDefined();
305 |     expect(result.refs?.value).toEqual([]);
306 |     expect(result.refs?.count).toBe(0);
307 |   });
308 | });
309 | 
```

--------------------------------------------------------------------------------
/memory/tasks_memory_2025-05-26T16-18-03.json:
--------------------------------------------------------------------------------

```json
  1 | {
  2 |   "tasks": [
  3 |     {
  4 |       "id": "1c881b0f-7fd6-4184-89f5-1676a56e3719",
  5 |       "name": "Fix shared type definitions with explicit any warnings",
  6 |       "description": "Replace explicit 'any' types in shared type definitions with proper TypeScript types to resolve ESLint warnings. This includes RequestHandler return type and ToolDefinition inputSchema type.",
  7 |       "notes": "These are core type definitions used throughout the project, so changes must maintain backward compatibility. The CallToolResult type is the standard MCP SDK return type for tool responses.",
  8 |       "status": "completed",
  9 |       "dependencies": [],
 10 |       "createdAt": "2025-05-26T15:23:09.065Z",
 11 |       "updatedAt": "2025-05-26T15:33:17.167Z",
 12 |       "relatedFiles": [
 13 |         {
 14 |           "path": "src/shared/types/request-handler.ts",
 15 |           "type": "TO_MODIFY",
 16 |           "description": "Contains RequestHandler interface with 'any' return type",
 17 |           "lineStart": 14,
 18 |           "lineEnd": 16
 19 |         },
 20 |         {
 21 |           "path": "src/shared/types/tool-definition.ts",
 22 |           "type": "TO_MODIFY",
 23 |           "description": "Contains ToolDefinition interface with 'any' inputSchema type",
 24 |           "lineStart": 4,
 25 |           "lineEnd": 8
 26 |         }
 27 |       ],
 28 |       "implementationGuide": "1. Update src/shared/types/request-handler.ts line 15: Change 'any' to 'Promise<CallToolResult>' where CallToolResult is imported from '@modelcontextprotocol/sdk/types.js'\n2. Update src/shared/types/tool-definition.ts line 7: Change 'any' to 'JSONSchema7' where JSONSchema7 is imported from 'json-schema'\n3. Add necessary imports at the top of each file\n4. Ensure all existing functionality remains unchanged",
 29 |       "verificationCriteria": "1. ESLint warnings for these files should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Import statements should be properly added",
 30 |       "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.",
 31 |       "summary": "Successfully replaced explicit 'any' types in shared type definitions with proper TypeScript types. Updated RequestHandler return type to use CallToolResult union type for backward compatibility, and ToolDefinition inputSchema to use JsonSchema7Type from zod-to-json-schema. ESLint warnings for these files are resolved, TypeScript compilation succeeds for the core types, and all existing functionality remains unchanged with proper imports added.",
 32 |       "completedAt": "2025-05-26T15:33:17.165Z"
 33 |     },
 34 |     {
 35 |       "id": "17aa94fe-24d4-4a8b-a127-ef27e121de38",
 36 |       "name": "Fix Azure DevOps client type warnings",
 37 |       "description": "Replace 'any' types in Azure DevOps client files with proper types from the azure-devops-node-api library to resolve 7 ESLint warnings in src/clients/azure-devops.ts.",
 38 |       "notes": "The azure-devops-node-api library provides comprehensive TypeScript interfaces. Prefer using existing library types over creating custom ones.",
 39 |       "status": "completed",
 40 |       "dependencies": [
 41 |         {
 42 |           "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719"
 43 |         }
 44 |       ],
 45 |       "createdAt": "2025-05-26T15:23:09.065Z",
 46 |       "updatedAt": "2025-05-26T15:39:30.003Z",
 47 |       "relatedFiles": [
 48 |         {
 49 |           "path": "src/clients/azure-devops.ts",
 50 |           "type": "TO_MODIFY",
 51 |           "description": "Contains 7 'any' type warnings that need proper typing",
 52 |           "lineStart": 1,
 53 |           "lineEnd": 500
 54 |         }
 55 |       ],
 56 |       "implementationGuide": "1. Examine each 'any' usage in src/clients/azure-devops.ts at lines 78, 158, 244, 305, 345, 453, 484\n2. Replace with appropriate types from azure-devops-node-api interfaces\n3. Common patterns: Use TeamProject, GitRepository, WorkItem, BuildDefinition types\n4. For API responses, use the specific interface types provided by the library\n5. If no specific type exists, create a minimal interface with required properties",
 57 |       "verificationCriteria": "1. All 7 ESLint warnings in src/clients/azure-devops.ts should be resolved\n2. TypeScript compilation should succeed\n3. Existing functionality should remain unchanged\n4. Types should be imported from azure-devops-node-api where available",
 58 |       "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.",
 59 |       "summary": "Successfully fixed all 7 ESLint warnings in src/clients/azure-devops.ts by replacing 'any' types with proper TypeScript interfaces. Created AzureDevOpsApiErrorResponse interface for Azure DevOps API error responses and replaced Record<string, any> with Record<string, string> for payload objects. All ESLint warnings are now resolved while maintaining existing functionality and backward compatibility.",
 60 |       "completedAt": "2025-05-26T15:39:30.002Z"
 61 |     },
 62 |     {
 63 |       "id": "d971e510-94cc-4f12-a1e8-a0ac35d57b7f",
 64 |       "name": "Fix feature-specific type warnings",
 65 |       "description": "Replace 'any' types in feature modules with proper Azure DevOps API types to resolve remaining ESLint warnings in projects, pull-requests, and repositories features.",
 66 |       "notes": "Each feature module should use the most specific Azure DevOps API type available. Check existing working features for type usage patterns.",
 67 |       "status": "completed",
 68 |       "dependencies": [
 69 |         {
 70 |           "taskId": "1c881b0f-7fd6-4184-89f5-1676a56e3719"
 71 |         }
 72 |       ],
 73 |       "createdAt": "2025-05-26T15:23:09.065Z",
 74 |       "updatedAt": "2025-05-26T15:51:24.788Z",
 75 |       "relatedFiles": [
 76 |         {
 77 |           "path": "src/features/projects/get-project-details/feature.ts",
 78 |           "type": "TO_MODIFY",
 79 |           "description": "Contains 'any' type warning at line 198"
 80 |         },
 81 |         {
 82 |           "path": "src/features/pull-requests/types.ts",
 83 |           "type": "TO_MODIFY",
 84 |           "description": "Contains 'any' type warnings at lines 20, 83"
 85 |         },
 86 |         {
 87 |           "path": "src/features/pull-requests/update-pull-request/feature.ts",
 88 |           "type": "TO_MODIFY",
 89 |           "description": "Contains 'any' type warnings at lines 33, 144, 213, 254"
 90 |         },
 91 |         {
 92 |           "path": "src/features/repositories/get-all-repositories-tree/feature.ts",
 93 |           "type": "TO_MODIFY",
 94 |           "description": "Contains 'any' type warning at line 231"
 95 |         },
 96 |         {
 97 |           "path": "src/shared/auth/client-factory.ts",
 98 |           "type": "TO_MODIFY",
 99 |           "description": "Contains 'any' type warning at line 282"
100 |         }
101 |       ],
102 |       "implementationGuide": "1. Fix src/features/projects/get-project-details/feature.ts line 198: Use TeamProject or TeamProjectReference type\n2. Fix src/features/pull-requests/types.ts lines 20, 83: Use GitPullRequest related interfaces\n3. Fix src/features/pull-requests/update-pull-request/feature.ts lines 33, 144, 213, 254: Use GitPullRequest and JsonPatchOperation types\n4. Fix src/features/repositories/get-all-repositories-tree/feature.ts line 231: Use GitTreeRef or GitItem type\n5. Fix src/shared/auth/client-factory.ts line 282: Use proper authentication credential type\n6. Import types from azure-devops-node-api/interfaces/",
103 |       "verificationCriteria": "1. All remaining ESLint 'any' type warnings should be resolved\n2. TypeScript compilation should succeed\n3. All existing tests should continue to pass\n4. Types should be consistent with Azure DevOps API documentation",
104 |       "analysisResult": "Fix lint issues and get unit tests passing in the MCP Azure DevOps server project. The solution addresses 18 TypeScript 'any' type warnings by replacing them with proper types from Azure DevOps Node API and MCP SDK, and resolves 1 failing unit test by handling empty test files. All changes maintain backward compatibility and follow existing project patterns.",
105 |       "summary": "Successfully fixed all feature-specific type warnings by replacing 'any' types with proper Azure DevOps API types. Fixed src/features/projects/get-project-details/feature.ts by using WorkItemTypeField interface, src/features/pull-requests/types.ts by replacing 'any' with specific union types, src/features/pull-requests/update-pull-request/feature.ts by using WebApi, AuthenticationMethod, and WorkItemRelation types, src/features/repositories/get-all-repositories-tree/feature.ts by using IGitApi type, and src/shared/auth/client-factory.ts by using IProfileApi type. All ESLint 'any' type warnings in the specified files have been resolved while maintaining type safety and consistency with Azure DevOps API documentation.",
106 |       "completedAt": "2025-05-26T15:51:24.787Z"
107 |     }
108 |   ]
109 | }
```

--------------------------------------------------------------------------------
/src/shared/auth/client-factory.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { WebApi } from 'azure-devops-node-api';
  2 | import { ICoreApi } from 'azure-devops-node-api/CoreApi';
  3 | import { IGitApi } from 'azure-devops-node-api/GitApi';
  4 | import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi';
  5 | import { IBuildApi } from 'azure-devops-node-api/BuildApi';
  6 | import { ITestApi } from 'azure-devops-node-api/TestApi';
  7 | import { IReleaseApi } from 'azure-devops-node-api/ReleaseApi';
  8 | import { ITaskAgentApi } from 'azure-devops-node-api/TaskAgentApi';
  9 | import { ITaskApi } from 'azure-devops-node-api/TaskApi';
 10 | import { IProfileApi } from 'azure-devops-node-api/ProfileApi';
 11 | import { AzureDevOpsError, AzureDevOpsAuthenticationError } from '../errors';
 12 | import { AuthConfig, createAuthClient } from './auth-factory';
 13 | 
 14 | /**
 15 |  * Azure DevOps Client
 16 |  *
 17 |  * Provides access to Azure DevOps APIs using the configured authentication method
 18 |  */
 19 | export class AzureDevOpsClient {
 20 |   private config: AuthConfig;
 21 |   private clientPromise: Promise<WebApi> | null = null;
 22 | 
 23 |   /**
 24 |    * Creates a new Azure DevOps client
 25 |    *
 26 |    * @param config Authentication configuration
 27 |    */
 28 |   constructor(config: AuthConfig) {
 29 |     this.config = config;
 30 |   }
 31 | 
 32 |   /**
 33 |    * Get the authenticated Azure DevOps client
 34 |    *
 35 |    * @returns The authenticated WebApi client
 36 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
 37 |    */
 38 |   private async getClient(): Promise<WebApi> {
 39 |     if (!this.clientPromise) {
 40 |       this.clientPromise = (async () => {
 41 |         try {
 42 |           return await createAuthClient(this.config);
 43 |         } catch (error) {
 44 |           // If it's already an AzureDevOpsError, rethrow it
 45 |           if (error instanceof AzureDevOpsError) {
 46 |             throw error;
 47 |           }
 48 |           // Otherwise, wrap it in an AzureDevOpsAuthenticationError
 49 |           throw new AzureDevOpsAuthenticationError(
 50 |             error instanceof Error
 51 |               ? `Authentication failed: ${error.message}`
 52 |               : 'Authentication failed: Unknown error',
 53 |           );
 54 |         }
 55 |       })();
 56 |     }
 57 |     return this.clientPromise;
 58 |   }
 59 | 
 60 |   /**
 61 |    * Get the underlying WebApi client
 62 |    *
 63 |    * @returns The authenticated WebApi client
 64 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
 65 |    */
 66 |   public async getWebApiClient(): Promise<WebApi> {
 67 |     return this.getClient();
 68 |   }
 69 | 
 70 |   /**
 71 |    * Check if the client is authenticated
 72 |    *
 73 |    * @returns True if the client is authenticated
 74 |    */
 75 |   public async isAuthenticated(): Promise<boolean> {
 76 |     try {
 77 |       const client = await this.getClient();
 78 |       return !!client;
 79 |     } catch {
 80 |       // Any error means we're not authenticated
 81 |       return false;
 82 |     }
 83 |   }
 84 | 
 85 |   /**
 86 |    * Get the Core API
 87 |    *
 88 |    * @returns The Core API client
 89 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
 90 |    */
 91 |   public async getCoreApi(): Promise<ICoreApi> {
 92 |     try {
 93 |       const client = await this.getClient();
 94 |       return await client.getCoreApi();
 95 |     } catch (error) {
 96 |       // If it's already an AzureDevOpsError, rethrow it
 97 |       if (error instanceof AzureDevOpsError) {
 98 |         throw error;
 99 |       }
100 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
101 |       throw new AzureDevOpsAuthenticationError(
102 |         error instanceof Error
103 |           ? `Failed to get Core API: ${error.message}`
104 |           : 'Failed to get Core API: Unknown error',
105 |       );
106 |     }
107 |   }
108 | 
109 |   /**
110 |    * Get the Git API
111 |    *
112 |    * @returns The Git API client
113 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
114 |    */
115 |   public async getGitApi(): Promise<IGitApi> {
116 |     try {
117 |       const client = await this.getClient();
118 |       return await client.getGitApi();
119 |     } catch (error) {
120 |       // If it's already an AzureDevOpsError, rethrow it
121 |       if (error instanceof AzureDevOpsError) {
122 |         throw error;
123 |       }
124 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
125 |       throw new AzureDevOpsAuthenticationError(
126 |         error instanceof Error
127 |           ? `Failed to get Git API: ${error.message}`
128 |           : 'Failed to get Git API: Unknown error',
129 |       );
130 |     }
131 |   }
132 | 
133 |   /**
134 |    * Get the Work Item Tracking API
135 |    *
136 |    * @returns The Work Item Tracking API client
137 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
138 |    */
139 |   public async getWorkItemTrackingApi(): Promise<IWorkItemTrackingApi> {
140 |     try {
141 |       const client = await this.getClient();
142 |       return await client.getWorkItemTrackingApi();
143 |     } catch (error) {
144 |       // If it's already an AzureDevOpsError, rethrow it
145 |       if (error instanceof AzureDevOpsError) {
146 |         throw error;
147 |       }
148 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
149 |       throw new AzureDevOpsAuthenticationError(
150 |         error instanceof Error
151 |           ? `Failed to get Work Item Tracking API: ${error.message}`
152 |           : 'Failed to get Work Item Tracking API: Unknown error',
153 |       );
154 |     }
155 |   }
156 | 
157 |   /**
158 |    * Get the Build API
159 |    *
160 |    * @returns The Build API client
161 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
162 |    */
163 |   public async getBuildApi(): Promise<IBuildApi> {
164 |     try {
165 |       const client = await this.getClient();
166 |       return await client.getBuildApi();
167 |     } catch (error) {
168 |       // If it's already an AzureDevOpsError, rethrow it
169 |       if (error instanceof AzureDevOpsError) {
170 |         throw error;
171 |       }
172 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
173 |       throw new AzureDevOpsAuthenticationError(
174 |         error instanceof Error
175 |           ? `Failed to get Build API: ${error.message}`
176 |           : 'Failed to get Build API: Unknown error',
177 |       );
178 |     }
179 |   }
180 | 
181 |   /**
182 |    * Get the Test API
183 |    *
184 |    * @returns The Test API client
185 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
186 |    */
187 |   public async getTestApi(): Promise<ITestApi> {
188 |     try {
189 |       const client = await this.getClient();
190 |       return await client.getTestApi();
191 |     } catch (error) {
192 |       // If it's already an AzureDevOpsError, rethrow it
193 |       if (error instanceof AzureDevOpsError) {
194 |         throw error;
195 |       }
196 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
197 |       throw new AzureDevOpsAuthenticationError(
198 |         error instanceof Error
199 |           ? `Failed to get Test API: ${error.message}`
200 |           : 'Failed to get Test API: Unknown error',
201 |       );
202 |     }
203 |   }
204 | 
205 |   /**
206 |    * Get the Release API
207 |    *
208 |    * @returns The Release API client
209 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
210 |    */
211 |   public async getReleaseApi(): Promise<IReleaseApi> {
212 |     try {
213 |       const client = await this.getClient();
214 |       return await client.getReleaseApi();
215 |     } catch (error) {
216 |       // If it's already an AzureDevOpsError, rethrow it
217 |       if (error instanceof AzureDevOpsError) {
218 |         throw error;
219 |       }
220 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
221 |       throw new AzureDevOpsAuthenticationError(
222 |         error instanceof Error
223 |           ? `Failed to get Release API: ${error.message}`
224 |           : 'Failed to get Release API: Unknown error',
225 |       );
226 |     }
227 |   }
228 | 
229 |   /**
230 |    * Get the Task Agent API
231 |    *
232 |    * @returns The Task Agent API client
233 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
234 |    */
235 |   public async getTaskAgentApi(): Promise<ITaskAgentApi> {
236 |     try {
237 |       const client = await this.getClient();
238 |       return await client.getTaskAgentApi();
239 |     } catch (error) {
240 |       // If it's already an AzureDevOpsError, rethrow it
241 |       if (error instanceof AzureDevOpsError) {
242 |         throw error;
243 |       }
244 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
245 |       throw new AzureDevOpsAuthenticationError(
246 |         error instanceof Error
247 |           ? `Failed to get Task Agent API: ${error.message}`
248 |           : 'Failed to get Task Agent API: Unknown error',
249 |       );
250 |     }
251 |   }
252 | 
253 |   /**
254 |    * Get the Task API
255 |    *
256 |    * @returns The Task API client
257 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
258 |    */
259 |   public async getTaskApi(): Promise<ITaskApi> {
260 |     try {
261 |       const client = await this.getClient();
262 |       return await client.getTaskApi();
263 |     } catch (error) {
264 |       // If it's already an AzureDevOpsError, rethrow it
265 |       if (error instanceof AzureDevOpsError) {
266 |         throw error;
267 |       }
268 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
269 |       throw new AzureDevOpsAuthenticationError(
270 |         error instanceof Error
271 |           ? `Failed to get Task API: ${error.message}`
272 |           : 'Failed to get Task API: Unknown error',
273 |       );
274 |     }
275 |   }
276 | 
277 |   /**
278 |    * Get the Profile API
279 |    *
280 |    * @returns The Profile API client
281 |    * @throws {AzureDevOpsAuthenticationError} If authentication fails
282 |    */
283 |   public async getProfileApi(): Promise<IProfileApi> {
284 |     try {
285 |       const client = await this.getClient();
286 |       return await client.getProfileApi();
287 |     } catch (error) {
288 |       // If it's already an AzureDevOpsError, rethrow it
289 |       if (error instanceof AzureDevOpsError) {
290 |         throw error;
291 |       }
292 |       // Otherwise, wrap it in an AzureDevOpsAuthenticationError
293 |       throw new AzureDevOpsAuthenticationError(
294 |         error instanceof Error
295 |           ? `Failed to get Profile API: ${error.message}`
296 |           : 'Failed to get Profile API: Unknown error',
297 |       );
298 |     }
299 |   }
300 | }
301 | 
```

--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-comments/feature.spec.int.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { WebApi } from 'azure-devops-node-api';
  2 | import { getPullRequestComments } from './feature';
  3 | import { listPullRequests } from '../list-pull-requests/feature';
  4 | import { addPullRequestComment } from '../add-pull-request-comment/feature';
  5 | import {
  6 |   getTestConnection,
  7 |   shouldSkipIntegrationTest,
  8 | } from '@/shared/test/test-helpers';
  9 | 
 10 | describe('getPullRequestComments integration', () => {
 11 |   let connection: WebApi | null = null;
 12 |   let projectName: string;
 13 |   let repositoryName: string;
 14 |   let pullRequestId: number;
 15 |   let testThreadId: number;
 16 | 
 17 |   // Generate unique identifiers using timestamp for comment content
 18 |   const timestamp = Date.now();
 19 |   const randomSuffix = Math.floor(Math.random() * 1000);
 20 | 
 21 |   beforeAll(async () => {
 22 |     // Get a real connection using environment variables
 23 |     connection = await getTestConnection();
 24 | 
 25 |     // Set up project and repository names from environment
 26 |     projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
 27 |     repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || '';
 28 | 
 29 |     // Skip setup if integration tests should be skipped
 30 |     if (shouldSkipIntegrationTest() || !connection) {
 31 |       return;
 32 |     }
 33 | 
 34 |     try {
 35 |       // Find an active pull request to use for testing
 36 |       const pullRequests = await listPullRequests(
 37 |         connection,
 38 |         projectName,
 39 |         repositoryName,
 40 |         {
 41 |           projectId: projectName,
 42 |           repositoryId: repositoryName,
 43 |           status: 'active',
 44 |           top: 1,
 45 |         },
 46 |       );
 47 | 
 48 |       if (!pullRequests || pullRequests.value.length === 0) {
 49 |         throw new Error('No active pull requests found for testing');
 50 |       }
 51 | 
 52 |       pullRequestId = pullRequests.value[0].pullRequestId!;
 53 |       console.log(`Using existing pull request #${pullRequestId} for testing`);
 54 | 
 55 |       // Create a test comment thread that we can use for specific thread tests
 56 |       const result = await addPullRequestComment(
 57 |         connection,
 58 |         projectName,
 59 |         repositoryName,
 60 |         pullRequestId,
 61 |         {
 62 |           projectId: projectName,
 63 |           repositoryId: repositoryName,
 64 |           pullRequestId,
 65 |           content: `Test comment thread ${timestamp}-${randomSuffix}`,
 66 |           status: 'active',
 67 |         },
 68 |       );
 69 | 
 70 |       testThreadId = result.thread!.id!;
 71 |       console.log(`Created test comment thread #${testThreadId} for testing`);
 72 |     } catch (error) {
 73 |       console.error('Error in test setup:', error);
 74 |       throw error;
 75 |     }
 76 |   });
 77 | 
 78 |   test('should get all comment threads from pull request with file path and line number', async () => {
 79 |     // Skip if integration tests should be skipped
 80 |     if (shouldSkipIntegrationTest() || !connection) {
 81 |       console.log('Skipping test due to missing connection');
 82 |       return;
 83 |     }
 84 | 
 85 |     // Skip if repository name is not defined
 86 |     if (!repositoryName) {
 87 |       console.log('Skipping test due to missing repository name');
 88 |       return;
 89 |     }
 90 | 
 91 |     const threads = await getPullRequestComments(
 92 |       connection,
 93 |       projectName,
 94 |       repositoryName,
 95 |       pullRequestId,
 96 |       {
 97 |         projectId: projectName,
 98 |         repositoryId: repositoryName,
 99 |         pullRequestId,
100 |       },
101 |     );
102 | 
103 |     // Verify threads were returned
104 |     expect(threads).toBeDefined();
105 |     expect(Array.isArray(threads)).toBe(true);
106 |     expect(threads.length).toBeGreaterThan(0);
107 | 
108 |     // Verify thread structure
109 |     const firstThread = threads[0];
110 |     expect(firstThread.id).toBeDefined();
111 |     expect(firstThread.comments).toBeDefined();
112 |     expect(Array.isArray(firstThread.comments)).toBe(true);
113 |     expect(firstThread.comments!.length).toBeGreaterThan(0);
114 | 
115 |     // Verify comment structure including new fields
116 |     const firstComment = firstThread.comments![0];
117 |     expect(firstComment.content).toBeDefined();
118 |     expect(firstComment.id).toBeDefined();
119 |     expect(firstComment.publishedDate).toBeDefined();
120 |     expect(firstComment.author).toBeDefined();
121 | 
122 |     // Verify new fields are present (may be undefined/null for general comments)
123 |     expect(firstComment).toHaveProperty('filePath');
124 |     expect(firstComment).toHaveProperty('rightFileStart');
125 |     expect(firstComment).toHaveProperty('rightFileEnd');
126 |     expect(firstComment).toHaveProperty('leftFileStart');
127 |     expect(firstComment).toHaveProperty('leftFileEnd');
128 |   }, 30000);
129 | 
130 |   test('should get a specific comment thread by ID with file path and line number', async () => {
131 |     // Skip if integration tests should be skipped
132 |     if (shouldSkipIntegrationTest() || !connection) {
133 |       console.log('Skipping test due to missing connection');
134 |       return;
135 |     }
136 | 
137 |     // Skip if repository name is not defined
138 |     if (!repositoryName) {
139 |       console.log('Skipping test due to missing repository name');
140 |       return;
141 |     }
142 | 
143 |     const threads = await getPullRequestComments(
144 |       connection,
145 |       projectName,
146 |       repositoryName,
147 |       pullRequestId,
148 |       {
149 |         projectId: projectName,
150 |         repositoryId: repositoryName,
151 |         pullRequestId,
152 |         threadId: testThreadId,
153 |       },
154 |     );
155 | 
156 |     // Verify only one thread was returned
157 |     expect(threads).toBeDefined();
158 |     expect(Array.isArray(threads)).toBe(true);
159 |     expect(threads.length).toBe(1);
160 | 
161 |     // Verify it's the correct thread
162 |     const thread = threads[0];
163 |     expect(thread.id).toBe(testThreadId);
164 |     expect(thread.comments).toBeDefined();
165 |     expect(Array.isArray(thread.comments)).toBe(true);
166 |     expect(thread.comments!.length).toBeGreaterThan(0);
167 | 
168 |     // Verify the comment content matches what we created
169 |     const comment = thread.comments![0];
170 |     expect(comment.content).toBe(
171 |       `Test comment thread ${timestamp}-${randomSuffix}`,
172 |     );
173 | 
174 |     // Verify new fields are present (may be undefined/null for general comments)
175 |     expect(comment).toHaveProperty('filePath');
176 |     expect(comment).toHaveProperty('rightFileStart');
177 |     expect(comment).toHaveProperty('rightFileEnd');
178 |     expect(comment).toHaveProperty('leftFileStart');
179 |     expect(comment).toHaveProperty('leftFileEnd');
180 |   }, 30000);
181 | 
182 |   test('should handle pagination with top parameter', async () => {
183 |     // Skip if integration tests should be skipped
184 |     if (shouldSkipIntegrationTest() || !connection) {
185 |       console.log('Skipping test due to missing connection');
186 |       return;
187 |     }
188 | 
189 |     // Skip if repository name is not defined
190 |     if (!repositoryName) {
191 |       console.log('Skipping test due to missing repository name');
192 |       return;
193 |     }
194 | 
195 |     // Get all threads first to compare
196 |     const allThreads = await getPullRequestComments(
197 |       connection,
198 |       projectName,
199 |       repositoryName,
200 |       pullRequestId,
201 |       {
202 |         projectId: projectName,
203 |         repositoryId: repositoryName,
204 |         pullRequestId,
205 |       },
206 |     );
207 | 
208 |     // Then get with pagination
209 |     const paginatedThreads = await getPullRequestComments(
210 |       connection,
211 |       projectName,
212 |       repositoryName,
213 |       pullRequestId,
214 |       {
215 |         projectId: projectName,
216 |         repositoryId: repositoryName,
217 |         pullRequestId,
218 |         top: 1,
219 |       },
220 |     );
221 | 
222 |     // Verify pagination
223 |     expect(paginatedThreads).toBeDefined();
224 |     expect(Array.isArray(paginatedThreads)).toBe(true);
225 |     expect(paginatedThreads.length).toBe(1);
226 |     expect(paginatedThreads.length).toBeLessThanOrEqual(allThreads.length);
227 | 
228 |     // Verify the thread structure is the same
229 |     const thread = paginatedThreads[0];
230 |     expect(thread.id).toBeDefined();
231 |     expect(thread.comments).toBeDefined();
232 |     expect(Array.isArray(thread.comments)).toBe(true);
233 |     expect(thread.comments!.length).toBeGreaterThan(0);
234 | 
235 |     // Verify new fields are present in paginated results
236 |     const comment = thread.comments![0];
237 |     expect(comment).toHaveProperty('filePath');
238 |     expect(comment).toHaveProperty('rightFileStart');
239 |     expect(comment).toHaveProperty('rightFileEnd');
240 |     expect(comment).toHaveProperty('leftFileStart');
241 |     expect(comment).toHaveProperty('leftFileEnd');
242 |   }, 30000);
243 | 
244 |   test('should handle includeDeleted parameter', async () => {
245 |     // Skip if integration tests should be skipped
246 |     if (shouldSkipIntegrationTest() || !connection) {
247 |       console.log('Skipping test due to missing connection');
248 |       return;
249 |     }
250 | 
251 |     // Skip if repository name is not defined
252 |     if (!repositoryName) {
253 |       console.log('Skipping test due to missing repository name');
254 |       return;
255 |     }
256 | 
257 |     const threads = await getPullRequestComments(
258 |       connection,
259 |       projectName,
260 |       repositoryName,
261 |       pullRequestId,
262 |       {
263 |         projectId: projectName,
264 |         repositoryId: repositoryName,
265 |         pullRequestId,
266 |         includeDeleted: true,
267 |       },
268 |     );
269 | 
270 |     // We can only verify the call succeeds, as we can't guarantee deleted comments exist
271 |     expect(threads).toBeDefined();
272 |     expect(Array.isArray(threads)).toBe(true);
273 | 
274 |     // If there are any threads, verify they have the new fields
275 |     if (threads.length > 0) {
276 |       const thread = threads[0];
277 |       if (thread.comments && thread.comments.length > 0) {
278 |         const comment = thread.comments[0];
279 |         expect(comment).toHaveProperty('filePath');
280 |         expect(comment).toHaveProperty('rightFileStart');
281 |         expect(comment).toHaveProperty('rightFileEnd');
282 |         expect(comment).toHaveProperty('leftFileStart');
283 |         expect(comment).toHaveProperty('leftFileEnd');
284 |       }
285 |     }
286 |   }, 30000); // 30 second timeout for integration test
287 | });
288 | 
```

--------------------------------------------------------------------------------
/src/features/pull-requests/update-pull-request/feature.spec.unit.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { updatePullRequest } from './feature';
  2 | import { AzureDevOpsClient } from '../../../shared/auth/client-factory';
  3 | import { AzureDevOpsError } from '../../../shared/errors';
  4 | 
  5 | // Mock the AzureDevOpsClient
  6 | jest.mock('../../../shared/auth/client-factory');
  7 | 
  8 | describe('updatePullRequest', () => {
  9 |   const mockGetPullRequestById = jest.fn();
 10 |   const mockUpdatePullRequest = jest.fn();
 11 |   const mockGetPullRequestLabels = jest.fn();
 12 |   const mockCreatePullRequestLabel = jest.fn();
 13 |   const mockDeletePullRequestLabels = jest.fn();
 14 |   const mockUpdateWorkItem = jest.fn();
 15 |   const mockGetWorkItem = jest.fn();
 16 | 
 17 |   // Mock Git API
 18 |   const mockGitApi = {
 19 |     getPullRequestById: mockGetPullRequestById,
 20 |     updatePullRequest: mockUpdatePullRequest,
 21 |     getPullRequestLabels: mockGetPullRequestLabels,
 22 |     createPullRequestLabel: mockCreatePullRequestLabel,
 23 |     deletePullRequestLabels: mockDeletePullRequestLabels,
 24 |   };
 25 | 
 26 |   // Mock Work Item Tracking API
 27 |   const mockWorkItemTrackingApi = {
 28 |     updateWorkItem: mockUpdateWorkItem,
 29 |     getWorkItem: mockGetWorkItem,
 30 |   };
 31 | 
 32 |   // Mock connection
 33 |   const mockConnection = {
 34 |     getGitApi: jest.fn().mockResolvedValue(mockGitApi),
 35 |     getWorkItemTrackingApi: jest
 36 |       .fn()
 37 |       .mockResolvedValue(mockWorkItemTrackingApi),
 38 |   };
 39 | 
 40 |   const mockAzureDevopsClient = {
 41 |     getWebApiClient: jest.fn().mockResolvedValue(mockConnection),
 42 |     // ...other properties if needed
 43 |   };
 44 | 
 45 |   beforeEach(() => {
 46 |     jest.clearAllMocks();
 47 |     (AzureDevOpsClient as unknown as jest.Mock).mockImplementation(
 48 |       () => mockAzureDevopsClient,
 49 |     );
 50 |     mockGetPullRequestLabels.mockResolvedValue([]);
 51 |   });
 52 | 
 53 |   it('should throw error when pull request does not exist', async () => {
 54 |     mockGetPullRequestById.mockResolvedValueOnce(null);
 55 | 
 56 |     await expect(
 57 |       updatePullRequest({
 58 |         projectId: 'project-1',
 59 |         repositoryId: 'repo1',
 60 |         pullRequestId: 123,
 61 |       }),
 62 |     ).rejects.toThrow(AzureDevOpsError);
 63 |   });
 64 | 
 65 |   it('should update the pull request title and description', async () => {
 66 |     mockGetPullRequestById.mockResolvedValueOnce({
 67 |       repository: { id: 'repo1' },
 68 |     });
 69 | 
 70 |     mockUpdatePullRequest.mockResolvedValueOnce({
 71 |       title: 'Updated Title',
 72 |       description: 'Updated Description',
 73 |     });
 74 | 
 75 |     const result = await updatePullRequest({
 76 |       projectId: 'project-1',
 77 |       repositoryId: 'repo1',
 78 |       pullRequestId: 123,
 79 |       title: 'Updated Title',
 80 |       description: 'Updated Description',
 81 |     });
 82 | 
 83 |     expect(mockUpdatePullRequest).toHaveBeenCalledWith(
 84 |       {
 85 |         title: 'Updated Title',
 86 |         description: 'Updated Description',
 87 |       },
 88 |       'repo1',
 89 |       123,
 90 |       'project-1',
 91 |     );
 92 | 
 93 |     expect(result).toEqual({
 94 |       title: 'Updated Title',
 95 |       description: 'Updated Description',
 96 |     });
 97 |   });
 98 | 
 99 |   it('should update the pull request status when status is provided', async () => {
100 |     mockGetPullRequestById.mockResolvedValueOnce({
101 |       repository: { id: 'repo1' },
102 |     });
103 | 
104 |     mockUpdatePullRequest.mockResolvedValueOnce({
105 |       status: 2, // Abandoned
106 |     });
107 | 
108 |     const result = await updatePullRequest({
109 |       projectId: 'project-1',
110 |       repositoryId: 'repo1',
111 |       pullRequestId: 123,
112 |       status: 'abandoned',
113 |     });
114 | 
115 |     expect(mockUpdatePullRequest).toHaveBeenCalledWith(
116 |       {
117 |         status: 2, // Abandoned value
118 |       },
119 |       'repo1',
120 |       123,
121 |       'project-1',
122 |     );
123 | 
124 |     expect(result).toEqual({
125 |       status: 2, // Abandoned
126 |     });
127 |   });
128 | 
129 |   it('should throw error for invalid status', async () => {
130 |     mockGetPullRequestById.mockResolvedValueOnce({
131 |       repository: { id: 'repo1' },
132 |     });
133 | 
134 |     await expect(
135 |       updatePullRequest({
136 |         projectId: 'project-1',
137 |         repositoryId: 'repo1',
138 |         pullRequestId: 123,
139 |         status: 'invalid-status' as any,
140 |       }),
141 |     ).rejects.toThrow(AzureDevOpsError);
142 |   });
143 | 
144 |   it('should update the pull request draft status', async () => {
145 |     mockGetPullRequestById.mockResolvedValueOnce({
146 |       repository: { id: 'repo1' },
147 |     });
148 | 
149 |     mockUpdatePullRequest.mockResolvedValueOnce({
150 |       isDraft: true,
151 |     });
152 | 
153 |     const result = await updatePullRequest({
154 |       projectId: 'project-1',
155 |       repositoryId: 'repo1',
156 |       pullRequestId: 123,
157 |       isDraft: true,
158 |     });
159 | 
160 |     expect(mockUpdatePullRequest).toHaveBeenCalledWith(
161 |       {
162 |         isDraft: true,
163 |       },
164 |       'repo1',
165 |       123,
166 |       'project-1',
167 |     );
168 | 
169 |     expect(result).toEqual({
170 |       isDraft: true,
171 |     });
172 |   });
173 | 
174 |   it('should include additionalProperties in the update', async () => {
175 |     mockGetPullRequestById.mockResolvedValueOnce({
176 |       repository: { id: 'repo1' },
177 |     });
178 | 
179 |     mockUpdatePullRequest.mockResolvedValueOnce({
180 |       title: 'Title',
181 |       customProperty: 'custom value',
182 |     });
183 | 
184 |     const result = await updatePullRequest({
185 |       projectId: 'project-1',
186 |       repositoryId: 'repo1',
187 |       pullRequestId: 123,
188 |       additionalProperties: {
189 |         customProperty: 'custom value',
190 |       },
191 |     });
192 | 
193 |     expect(mockUpdatePullRequest).toHaveBeenCalledWith(
194 |       {
195 |         customProperty: 'custom value',
196 |       },
197 |       'repo1',
198 |       123,
199 |       'project-1',
200 |     );
201 | 
202 |     expect(result).toEqual({
203 |       title: 'Title',
204 |       customProperty: 'custom value',
205 |     });
206 |   });
207 | 
208 |   it('should add new tags to the pull request', async () => {
209 |     mockGetPullRequestById.mockResolvedValueOnce({
210 |       repository: { id: 'repo1' },
211 |       pullRequestId: 123,
212 |     });
213 | 
214 |     mockUpdatePullRequest.mockResolvedValueOnce({
215 |       pullRequestId: 123,
216 |     });
217 | 
218 |     mockGetPullRequestLabels.mockResolvedValueOnce([{ name: 'existing' }]);
219 | 
220 |     mockCreatePullRequestLabel.mockImplementation(
221 |       async (label: { name: string }) => ({
222 |         name: label.name,
223 |       }),
224 |     );
225 | 
226 |     const result = await updatePullRequest({
227 |       projectId: 'project-1',
228 |       repositoryId: 'repo1',
229 |       pullRequestId: 123,
230 |       addTags: ['New Tag', 'new tag', 'Another'],
231 |     });
232 | 
233 |     expect(mockGetPullRequestLabels).toHaveBeenCalledWith(
234 |       'repo1',
235 |       123,
236 |       'project-1',
237 |     );
238 |     expect(mockCreatePullRequestLabel).toHaveBeenCalledTimes(2);
239 |     expect(mockCreatePullRequestLabel).toHaveBeenCalledWith(
240 |       { name: 'New Tag' },
241 |       'repo1',
242 |       123,
243 |       'project-1',
244 |     );
245 |     expect(mockCreatePullRequestLabel).toHaveBeenCalledWith(
246 |       { name: 'Another' },
247 |       'repo1',
248 |       123,
249 |       'project-1',
250 |     );
251 |     expect(result.labels).toEqual([
252 |       { name: 'existing' },
253 |       { name: 'New Tag' },
254 |       { name: 'Another' },
255 |     ]);
256 |   });
257 | 
258 |   it('should remove tags and ignore missing ones', async () => {
259 |     mockGetPullRequestById.mockResolvedValueOnce({
260 |       repository: { id: 'repo1' },
261 |       pullRequestId: 123,
262 |     });
263 | 
264 |     mockUpdatePullRequest.mockResolvedValueOnce({
265 |       pullRequestId: 123,
266 |     });
267 | 
268 |     mockGetPullRequestLabels.mockResolvedValueOnce([
269 |       { name: 'keep' },
270 |       { name: 'remove' },
271 |     ]);
272 | 
273 |     mockDeletePullRequestLabels
274 |       .mockResolvedValueOnce(undefined)
275 |       .mockRejectedValueOnce({ statusCode: 404 });
276 | 
277 |     const result = await updatePullRequest({
278 |       projectId: 'project-1',
279 |       repositoryId: 'repo1',
280 |       pullRequestId: 123,
281 |       removeTags: ['remove', 'missing'],
282 |     });
283 | 
284 |     expect(mockDeletePullRequestLabels).toHaveBeenCalledTimes(2);
285 |     expect(mockDeletePullRequestLabels).toHaveBeenCalledWith(
286 |       'repo1',
287 |       123,
288 |       'remove',
289 |       'project-1',
290 |     );
291 |     expect(mockDeletePullRequestLabels).toHaveBeenCalledWith(
292 |       'repo1',
293 |       123,
294 |       'missing',
295 |       'project-1',
296 |     );
297 |     expect(result.labels).toEqual([{ name: 'keep' }]);
298 |   });
299 | 
300 |   it('should handle work item links', async () => {
301 |     // Define the artifactId that will be used
302 |     const artifactId = 'vstfs:///Git/PullRequestId/project-1/repo1/123';
303 | 
304 |     mockGetPullRequestById.mockResolvedValueOnce({
305 |       repository: { id: 'repo1' },
306 |       artifactId: artifactId, // Add the artifactId to the mock response
307 |     });
308 | 
309 |     mockUpdatePullRequest.mockResolvedValueOnce({
310 |       pullRequestId: 123,
311 |       repository: { id: 'repo1' },
312 |       artifactId: artifactId,
313 |     });
314 | 
315 |     // Mocks for work items to remove
316 |     mockGetWorkItem.mockResolvedValueOnce({
317 |       relations: [
318 |         {
319 |           rel: 'ArtifactLink',
320 |           url: artifactId, // Use the same artifactId here
321 |           attributes: {
322 |             name: 'Pull Request',
323 |           },
324 |         },
325 |       ],
326 |     });
327 | 
328 |     mockGetWorkItem.mockResolvedValueOnce({
329 |       relations: [
330 |         {
331 |           rel: 'ArtifactLink',
332 |           url: artifactId, // Use the same artifactId here
333 |           attributes: {
334 |             name: 'Pull Request',
335 |           },
336 |         },
337 |       ],
338 |     });
339 | 
340 |     await updatePullRequest({
341 |       projectId: 'project-1',
342 |       repositoryId: 'repo1',
343 |       pullRequestId: 123,
344 |       addWorkItemIds: [456, 789],
345 |       removeWorkItemIds: [101, 202],
346 |     });
347 | 
348 |     // Check that updateWorkItem was called for adding work items
349 |     expect(mockUpdateWorkItem).toHaveBeenCalledTimes(4); // 2 for add, 2 for remove
350 |     expect(mockUpdateWorkItem).toHaveBeenCalledWith(
351 |       null,
352 |       [
353 |         {
354 |           op: 'add',
355 |           path: '/relations/-',
356 |           value: {
357 |             rel: 'ArtifactLink',
358 |             url: 'vstfs:///Git/PullRequestId/project-1/repo1/123',
359 |             attributes: {
360 |               name: 'Pull Request',
361 |             },
362 |           },
363 |         },
364 |       ],
365 |       456,
366 |     );
367 | 
368 |     // Check for removing work items
369 |     expect(mockUpdateWorkItem).toHaveBeenCalledWith(
370 |       null,
371 |       [
372 |         {
373 |           op: 'remove',
374 |           path: '/relations/0',
375 |         },
376 |       ],
377 |       101,
378 |     );
379 |   });
380 | 
381 |   it('should wrap unexpected errors in a friendly error message', async () => {
382 |     mockGetPullRequestById.mockRejectedValueOnce(new Error('Unexpected'));
383 | 
384 |     await expect(
385 |       updatePullRequest({
386 |         projectId: 'project-1',
387 |         repositoryId: 'repo1',
388 |         pullRequestId: 123,
389 |       }),
390 |     ).rejects.toThrow(AzureDevOpsError);
391 |   });
392 | });
393 | 
```

--------------------------------------------------------------------------------
/.github/copilot-instructions.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Azure DevOps MCP Server - Development Instructions
  2 | 
  3 | Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.
  4 | 
  5 | ## Prerequisites
  6 | 
  7 | You need to have `Node.js` and `npm` installed. Node 20 (LTS) or later is recommended for development.
  8 | 
  9 | ## Building and Running
 10 | 
 11 | ### 1. Install Dependencies
 12 | ```bash
 13 | npm install  # Takes ~25 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
 14 | ```
 15 | 
 16 | ### 2. Build the Server
 17 | ```bash
 18 | npm run build  # Takes ~5 seconds. Compiles TypeScript to `dist/` directory.
 19 | ```
 20 | 
 21 | ### 3. Run Tests
 22 | ```bash
 23 | # Unit tests (no Azure DevOps credentials required)
 24 | npm run test:unit  # Takes ~19 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
 25 | 
 26 | # Integration tests (requires Azure DevOps credentials)
 27 | npm run test:int  # Takes ~18 seconds. NEVER CANCEL. Set timeout to 60+ seconds.
 28 | 
 29 | # E2E tests (requires Azure DevOps credentials)
 30 | npm run test:e2e  # Takes ~6 seconds. NEVER CANCEL. Set timeout to 30+ seconds.
 31 | 
 32 | # All tests
 33 | npm test  # Takes ~45 seconds total. NEVER CANCEL. Set timeout to 90+ seconds.
 34 | ```
 35 | 
 36 | ### 4. Code Quality
 37 | ```bash
 38 | npm run lint       # Takes ~3 seconds. Runs ESLint for code quality.
 39 | npm run lint:fix   # Auto-fix linting issues.
 40 | npm run format     # Takes ~3 seconds. Runs Prettier for code formatting.
 41 | ```
 42 | 
 43 | ### 5. Run the Server
 44 | ```bash
 45 | # IMPORTANT: Always configure environment first using `.env` file (copy from `.env.example`)
 46 | 
 47 | npm run dev        # Development mode with auto-restart using ts-node-dev
 48 | npm run start      # Production mode - runs compiled version from `dist/index.js`
 49 | npm run inspector  # Debug mode with MCP Inspector tool
 50 | ```
 51 | 
 52 | ## Environment Setup
 53 | 
 54 | Copy `.env.example` to `.env` and configure Azure DevOps credentials:
 55 | 
 56 | ```bash
 57 | cp .env.example .env
 58 | # Edit .env with your Azure DevOps credentials
 59 | ```
 60 | 
 61 | **Required environment variables:**
 62 | ```
 63 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
 64 | AZURE_DEVOPS_AUTH_METHOD=pat  # Options: pat, azure-identity, azure-cli
 65 | AZURE_DEVOPS_PAT=your-personal-access-token  # For PAT auth
 66 | AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name  # Optional
 67 | ```
 68 | 
 69 | **Alternative setup methods:**
 70 | - Use `./setup_env.sh` script for interactive environment setup with Azure CLI
 71 | - See `docs/authentication.md` for comprehensive authentication guides
 72 | - Copy from `docs/examples/` directory for ready-to-use configurations (PAT, Azure Identity, Azure CLI)
 73 | - For CI/CD environments: Reference `docs/ci-setup.md` for secrets configuration
 74 | 
 75 | **Note:** The application will fail gracefully with clear error messages if credentials are missing.
 76 | 
 77 | ## Submitting Changes
 78 | 
 79 | Before submitting a PR, ensure:
 80 | 
 81 | 1. **Linting and formatting pass:**
 82 |    ```bash
 83 |    npm run lint:fix && npm run format
 84 |    ```
 85 | 
 86 | 2. **Build succeeds:**
 87 |    ```bash
 88 |    npm run build
 89 |    ```
 90 | 
 91 | 3. **Unit tests pass:**
 92 |    ```bash
 93 |    npm run test:unit
 94 |    ```
 95 | 
 96 | 4. **Manual testing complete:**
 97 |    - Configure `.env` file (copy from `.env.example` and update values)
 98 |    - Test server startup: `npm run dev` (should start or fail gracefully with clear error messages)
 99 |    - Test MCP protocol using `npm run inspector` (requires working Azure DevOps credentials)
100 | 
101 | 5. **Integration/E2E tests pass** (if you have Azure DevOps credentials):
102 |    ```bash
103 |    npm run test:int && npm run test:e2e
104 |    ```
105 | 
106 | 6. **Conventional commits:**
107 |    ```bash
108 |    npm run commit  # Use this for guided commit message creation
109 |    ```
110 | 
111 | **Note:** Unit tests must pass even without Azure DevOps credentials - they use mocks for all external dependencies.
112 | 
113 | ## Project Architecture
114 | 
115 | - **TypeScript** project with strict configuration (`tsconfig.json`)
116 | - **Feature-based architecture:** Each Azure DevOps feature area is a separate module in `src/features/`
117 |   - Example: `src/features/work-items/`, `src/features/projects/`, `src/features/repositories/`
118 | - **MCP Protocol** implementation for AI assistant integration using `@modelcontextprotocol/sdk`
119 | - **Test Strategy:** Testing Trophy approach (see `docs/testing/README.md`)
120 |   - Unit tests: `.spec.unit.ts` (mock all external dependencies, focus on logic)
121 |   - Integration tests: `.spec.int.ts` (test with real Azure DevOps APIs, requires credentials)
122 |   - E2E tests: `.spec.e2e.ts` (test complete MCP server functionality)
123 |   - Tests are co-located with feature code
124 | - **Path aliases:** Use `@/` instead of relative imports (e.g., `import { something } from '@/shared/utils'`)
125 | 
126 | ### Key Directories
127 | - `src/index.ts` and `src/server.ts` - Main server entry points
128 | - `src/features/[feature-name]/` - Feature modules
129 | - `src/shared/` - Shared utilities (auth, errors, types, config)
130 | - `src/clients/azure-devops.ts` - Azure DevOps client
131 | - `tests/setup.ts` - Test configuration
132 | - `docs/` - Comprehensive documentation (authentication, testing, tools)
133 | 
134 | ## Adding a New Feature
135 | 
136 | Follow the Feature Module pattern used throughout the codebase:
137 | 
138 | 1. **Create feature module directory:**
139 |    ```
140 |    src/features/[feature-name]/
141 |    ```
142 | 
143 | 2. **Add required files:**
144 |    - `feature.ts` - Core feature logic
145 |    - `schema.ts` - Zod schemas for input/output validation
146 |    - `tool-definitions.ts` - MCP tool definitions
147 |    - `index.ts` - Exports and request handlers
148 | 
149 | 3. **Add test files:**
150 |    - `feature.spec.unit.ts` - Unit tests (required)
151 |    - `feature.spec.int.ts` - Integration tests (if applicable)
152 | 
153 | 4. **Register the feature:**
154 |    - Add to `src/server.ts` following existing patterns
155 | 
156 | 5. **Reference existing modules:**
157 |    - See `src/features/work-items/` or `src/features/projects/` for complete examples
158 |    - Follow the same structure and naming conventions
159 | 
160 | ## Modifying Existing Features
161 | 
162 | When updating existing features:
163 | 
164 | 1. **Update core logic:** `src/features/[feature-name]/feature.ts`
165 | 2. **Update schemas:** `schemas.ts` (if input/output changes)
166 | 3. **Update tool definitions:** `tool-definitions.ts` (if MCP interface changes)
167 | 4. **Update or add tests:** Always update existing tests or add new ones
168 | 5. **Run validation:** Execute the full validation workflow before committing
169 | 
170 | ## Dependencies
171 | 
172 | **Core libraries:**
173 | - `@modelcontextprotocol/sdk` - MCP protocol implementation
174 | - `azure-devops-node-api` - Azure DevOps REST APIs
175 | - `@azure/identity` - Azure authentication
176 | - `zod` - Schema validation and type safety
177 | 
178 | **Development tools:**
179 | - `jest` - Testing framework (with separate configs for unit/int/e2e tests)
180 | - `ts-node-dev` - Development server with auto-restart
181 | - `eslint` + `prettier` - Code quality and formatting
182 | - `husky` - Git hooks for commit validation
183 | 
184 | **CI/CD:**
185 | - GitHub Actions workflow: `.github/workflows/main.yml`
186 | - Runs on PRs to `main`: Install → Lint → Build → Unit Tests → Integration Tests → E2E Tests
187 | - Integration and E2E tests require Azure DevOps secrets in CI
188 | - Release automation with `release-please`
189 | 
190 | ## Documentation
191 | 
192 | The repository has extensive documentation. Reference these for specific scenarios:
193 | 
194 | ### Authentication & Configuration
195 | - `docs/authentication.md` - Complete authentication guide (PAT, Azure Identity, Azure CLI)
196 | - `docs/azure-identity-authentication.md` - Detailed Azure Identity setup and troubleshooting
197 | - `docs/ci-setup.md` - CI/CD environment setup and secrets configuration
198 | - `docs/examples/` - Ready-to-use environment configuration examples
199 | 
200 | ### Testing & Development
201 | - `docs/testing/README.md` - Testing Trophy approach, test types, and testing philosophy
202 | - `docs/testing/setup.md` - Test environment setup, import patterns, VSCode integration
203 | - `CONTRIBUTING.md` - Development practices, commit guidelines, and workflow
204 | 
205 | ### Tool & API Documentation  
206 | - `docs/tools/README.md` - Complete tool catalog with examples and response formats
207 | - `docs/tools/[feature].md` - Detailed docs for each feature area (work-items, projects, etc.)
208 | - `docs/tools/resources.md` - Resource URI patterns for accessing repository content
209 | 
210 | ### When to Reference
211 | - **Starting new features:** Review `CONTRIBUTING.md` and `docs/testing/README.md`
212 | - **Authentication issues:** Check `docs/authentication.md` first
213 | - **Available tools:** Browse `docs/tools/README.md`
214 | - **CI/CD problems:** Reference `docs/ci-setup.md`
215 | - **Testing patterns:** Use `docs/testing/setup.md`
216 | - **Environment setup:** Copy from `docs/examples/`
217 | 
218 | ## Troubleshooting
219 | 
220 | **Build fails:**
221 | - Check TypeScript errors
222 | - Ensure all imports are valid
223 | - Verify `tsconfig.json` paths configuration
224 | 
225 | **Tests fail:**
226 | - Unit tests: Should pass without Azure DevOps credentials (they use mocks)
227 | - Integration/E2E tests: Check `.env` file has valid Azure DevOps credentials
228 | - See `docs/testing/setup.md` for environment variables and patterns
229 | 
230 | **Lint errors:**
231 | - Run `npm run lint:fix` to auto-fix common issues
232 | - Check ESLint rules in `.eslintrc.json`
233 | 
234 | **Server won't start:**
235 | - Verify `.env` file configuration
236 | - Check error messages for missing environment variables
237 | - See `docs/authentication.md` for comprehensive setup guides
238 | 
239 | **Authentication issues:**
240 | - See `docs/authentication.md` for comprehensive troubleshooting
241 | - For CI/CD: Reference `docs/ci-setup.md` for proper secrets configuration
242 | 
243 | **Import errors:**
244 | - Use `@/` path aliases instead of relative imports
245 | - Verify `tsconfig.json` paths configuration
246 | 
247 | **Unknown tool capabilities:**
248 | - Browse `docs/tools/README.md` for complete tool documentation
249 | 
250 | ## Available Skills
251 | 
252 | Skills are modular, self-contained packages that extend capabilities with specialized knowledge, workflows, and tools. Reference these skills when working on related tasks.
253 | 
254 | | Skill Name | Use When... | Path |
255 | |------------|-------------|------|
256 | | skill-creator | Creating a new skill or updating an existing skill that extends capabilities with specialized knowledge, workflows, or tool integrations | [.github/skills/skill-creator/SKILL.md](.github/skills/skill-creator/SKILL.md) |
257 | | azure-devops-rest-api | Implementing new Azure DevOps API integrations, exploring API capabilities, understanding request/response formats, or referencing the official OpenAPI specifications from the vsts-rest-api-specs repository | [.github/skills/azure-devops-rest-api/SKILL.md](.github/skills/azure-devops-rest-api/SKILL.md) |
```

--------------------------------------------------------------------------------
/src/features/projects/get-project-details/feature.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { WebApi } from 'azure-devops-node-api';
  2 | import {
  3 |   AzureDevOpsResourceNotFoundError,
  4 |   AzureDevOpsError,
  5 | } from '../../../shared/errors';
  6 | import {
  7 |   TeamProject,
  8 |   WebApiTeam,
  9 | } from 'azure-devops-node-api/interfaces/CoreInterfaces';
 10 | import { WorkItemField } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces';
 11 | 
 12 | // Type for work item type field with additional properties
 13 | interface WorkItemTypeField extends WorkItemField {
 14 |   isRequired?: boolean;
 15 |   isIdentity?: boolean;
 16 |   isPicklist?: boolean;
 17 | }
 18 | 
 19 | /**
 20 |  * Options for getting project details
 21 |  */
 22 | export interface GetProjectDetailsOptions {
 23 |   projectId: string;
 24 |   includeProcess?: boolean;
 25 |   includeWorkItemTypes?: boolean;
 26 |   includeFields?: boolean;
 27 |   includeTeams?: boolean;
 28 |   expandTeamIdentity?: boolean;
 29 | }
 30 | 
 31 | /**
 32 |  * Process information with work item types
 33 |  */
 34 | interface ProcessInfo {
 35 |   id: string;
 36 |   name: string;
 37 |   description?: string;
 38 |   isDefault: boolean;
 39 |   type: string;
 40 |   workItemTypes?: WorkItemTypeInfo[];
 41 |   hierarchyInfo?: {
 42 |     portfolioBacklogs?: {
 43 |       name: string;
 44 |       workItemTypes: string[];
 45 |     }[];
 46 |     requirementBacklog?: {
 47 |       name: string;
 48 |       workItemTypes: string[];
 49 |     };
 50 |     taskBacklog?: {
 51 |       name: string;
 52 |       workItemTypes: string[];
 53 |     };
 54 |   };
 55 | }
 56 | 
 57 | /**
 58 |  * Work item type information with states and fields
 59 |  */
 60 | interface WorkItemTypeInfo {
 61 |   name: string;
 62 |   referenceName: string;
 63 |   description?: string;
 64 |   isDisabled: boolean;
 65 |   states?: {
 66 |     name: string;
 67 |     color?: string;
 68 |     stateCategory: string;
 69 |   }[];
 70 |   fields?: {
 71 |     name: string;
 72 |     referenceName: string;
 73 |     type: string;
 74 |     required?: boolean;
 75 |     isIdentity?: boolean;
 76 |     isPicklist?: boolean;
 77 |     description?: string;
 78 |   }[];
 79 | }
 80 | 
 81 | /**
 82 |  * Project details response
 83 |  */
 84 | interface ProjectDetails extends TeamProject {
 85 |   process?: ProcessInfo;
 86 |   teams?: WebApiTeam[];
 87 | }
 88 | 
 89 | /**
 90 |  * Get detailed information about a project
 91 |  *
 92 |  * @param connection The Azure DevOps WebApi connection
 93 |  * @param options Options for getting project details
 94 |  * @returns The project details
 95 |  * @throws {AzureDevOpsResourceNotFoundError} If the project is not found
 96 |  */
 97 | export async function getProjectDetails(
 98 |   connection: WebApi,
 99 |   options: GetProjectDetailsOptions,
100 | ): Promise<ProjectDetails> {
101 |   try {
102 |     const {
103 |       projectId,
104 |       includeProcess = false,
105 |       includeWorkItemTypes = false,
106 |       includeFields = false,
107 |       includeTeams = false,
108 |       expandTeamIdentity = false,
109 |     } = options;
110 | 
111 |     // Get the core API
112 |     const coreApi = await connection.getCoreApi();
113 | 
114 |     // Get the basic project information
115 |     const project = await coreApi.getProject(projectId);
116 | 
117 |     if (!project) {
118 |       throw new AzureDevOpsResourceNotFoundError(
119 |         `Project '${projectId}' not found`,
120 |       );
121 |     }
122 | 
123 |     // Initialize the result with the project information and ensure required properties
124 |     const result: ProjectDetails = {
125 |       ...project,
126 |       // Ensure capabilities is always defined
127 |       capabilities: project.capabilities || {
128 |         versioncontrol: { sourceControlType: 'Git' },
129 |         processTemplate: { templateName: 'Unknown', templateTypeId: 'unknown' },
130 |       },
131 |     };
132 | 
133 |     // If teams are requested, get them
134 |     if (includeTeams) {
135 |       const teams = await coreApi.getTeams(projectId, expandTeamIdentity);
136 |       result.teams = teams;
137 |     }
138 | 
139 |     // If process information is requested, get it
140 |     if (includeProcess) {
141 |       // Get the process template ID from the project capabilities
142 |       const processTemplateId =
143 |         project.capabilities?.processTemplate?.templateTypeId || 'unknown';
144 | 
145 |       // Always create a process object, even if we don't have a template ID
146 |       // In a real implementation, we would use the Process API
147 |       // Since it's not directly available in the WebApi type, we'll simulate it
148 |       // This is a simplified version for the implementation
149 |       // In a real implementation, you would need to use the appropriate API
150 | 
151 |       // Create the process info object directly
152 |       const processInfo: ProcessInfo = {
153 |         id: processTemplateId,
154 |         name: project.capabilities?.processTemplate?.templateName || 'Unknown',
155 |         description: 'Process template for the project',
156 |         isDefault: true,
157 |         type: 'system',
158 |       };
159 | 
160 |       // If work item types are requested, get them
161 |       if (includeWorkItemTypes) {
162 |         // In a real implementation, we would get work item types from the API
163 |         // For now, we'll use the work item tracking API to get basic types
164 |         const workItemTrackingApi = await connection.getWorkItemTrackingApi();
165 |         const workItemTypes =
166 |           await workItemTrackingApi.getWorkItemTypes(projectId);
167 | 
168 |         // Map the work item types to our format
169 |         const processWorkItemTypes: WorkItemTypeInfo[] = workItemTypes.map(
170 |           (wit) => {
171 |             // Create the work item type info object
172 |             const workItemTypeInfo: WorkItemTypeInfo = {
173 |               name: wit.name || 'Unknown',
174 |               referenceName:
175 |                 wit.referenceName || `System.Unknown.${Date.now()}`,
176 |               description: wit.description,
177 |               isDisabled: false,
178 |               states: [
179 |                 { name: 'New', stateCategory: 'Proposed' },
180 |                 { name: 'Active', stateCategory: 'InProgress' },
181 |                 { name: 'Resolved', stateCategory: 'InProgress' },
182 |                 { name: 'Closed', stateCategory: 'Completed' },
183 |               ],
184 |             };
185 | 
186 |             // If fields are requested, don't add fields here - we'll add them after fetching from API
187 |             return workItemTypeInfo;
188 |           },
189 |         );
190 | 
191 |         // If fields are requested, get the field definitions from the API
192 |         if (includeFields) {
193 |           try {
194 |             // Instead of getting all fields and applying them to all work item types,
195 |             // let's get the fields specific to each work item type
196 |             for (const wit of processWorkItemTypes) {
197 |               try {
198 |                 // Get fields specific to this work item type using the specialized method
199 |                 const typeSpecificFields =
200 |                   await workItemTrackingApi.getWorkItemTypeFieldsWithReferences(
201 |                     projectId,
202 |                     wit.name,
203 |                   );
204 | 
205 |                 // Map the fields to our format
206 |                 wit.fields = typeSpecificFields.map(
207 |                   (field: WorkItemTypeField) => ({
208 |                     name: field.name || 'Unknown',
209 |                     referenceName: field.referenceName || 'Unknown',
210 |                     type: field.type?.toString().toLowerCase() || 'string',
211 |                     required: field.isRequired || false,
212 |                     isIdentity: field.isIdentity || false,
213 |                     isPicklist: field.isPicklist || false,
214 |                     description: field.description,
215 |                   }),
216 |                 );
217 |               } catch (typeFieldError) {
218 |                 console.error(
219 |                   `Error fetching fields for work item type ${wit.name}:`,
220 |                   typeFieldError,
221 |                 );
222 | 
223 |                 // Fallback to basic fields
224 |                 wit.fields = [
225 |                   {
226 |                     name: 'Title',
227 |                     referenceName: 'System.Title',
228 |                     type: 'string',
229 |                     required: true,
230 |                   },
231 |                   {
232 |                     name: 'Description',
233 |                     referenceName: 'System.Description',
234 |                     type: 'html',
235 |                     required: false,
236 |                   },
237 |                 ];
238 |               }
239 |             }
240 |           } catch (fieldError) {
241 |             console.error('Error in field processing:', fieldError);
242 | 
243 |             // Fallback to default fields if API call fails
244 |             processWorkItemTypes.forEach((wit) => {
245 |               wit.fields = [
246 |                 {
247 |                   name: 'Title',
248 |                   referenceName: 'System.Title',
249 |                   type: 'string',
250 |                   required: true,
251 |                 },
252 |                 {
253 |                   name: 'Description',
254 |                   referenceName: 'System.Description',
255 |                   type: 'html',
256 |                   required: false,
257 |                 },
258 |               ];
259 |             });
260 |           }
261 |         }
262 | 
263 |         processInfo.workItemTypes = processWorkItemTypes;
264 | 
265 |         // Add hierarchy information if available
266 |         // This is a simplified version - in a real implementation, you would
267 |         // need to get the backlog configuration and map it to the work item types
268 |         processInfo.hierarchyInfo = {
269 |           portfolioBacklogs: [
270 |             {
271 |               name: 'Epics',
272 |               workItemTypes: processWorkItemTypes
273 |                 .filter(
274 |                   (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'epic',
275 |                 )
276 |                 .map((wit: WorkItemTypeInfo) => wit.name),
277 |             },
278 |             {
279 |               name: 'Features',
280 |               workItemTypes: processWorkItemTypes
281 |                 .filter(
282 |                   (wit: WorkItemTypeInfo) =>
283 |                     wit.name.toLowerCase() === 'feature',
284 |                 )
285 |                 .map((wit: WorkItemTypeInfo) => wit.name),
286 |             },
287 |           ],
288 |           requirementBacklog: {
289 |             name: 'Stories',
290 |             workItemTypes: processWorkItemTypes
291 |               .filter(
292 |                 (wit: WorkItemTypeInfo) =>
293 |                   wit.name.toLowerCase() === 'user story' ||
294 |                   wit.name.toLowerCase() === 'bug',
295 |               )
296 |               .map((wit: WorkItemTypeInfo) => wit.name),
297 |           },
298 |           taskBacklog: {
299 |             name: 'Tasks',
300 |             workItemTypes: processWorkItemTypes
301 |               .filter(
302 |                 (wit: WorkItemTypeInfo) => wit.name.toLowerCase() === 'task',
303 |               )
304 |               .map((wit: WorkItemTypeInfo) => wit.name),
305 |           },
306 |         };
307 |       }
308 | 
309 |       // Always set the process on the result
310 |       result.process = processInfo;
311 |     }
312 | 
313 |     return result;
314 |   } catch (error) {
315 |     if (error instanceof AzureDevOpsError) {
316 |       throw error;
317 |     }
318 |     throw new Error(
319 |       `Failed to get project details: ${error instanceof Error ? error.message : String(error)}`,
320 |     );
321 |   }
322 | }
323 | 
```

--------------------------------------------------------------------------------
/docs/authentication.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Authentication Guide for Azure DevOps MCP Server
  2 | 
  3 | This guide provides detailed information about the authentication methods supported by the Azure DevOps MCP Server, including setup instructions, configuration examples, and troubleshooting tips.
  4 | 
  5 | ## Supported Authentication Methods
  6 | 
  7 | The Azure DevOps MCP Server supports three authentication methods:
  8 | 
  9 | 1. **Personal Access Token (PAT)** - Simple token-based authentication
 10 | 2. **Azure Identity (DefaultAzureCredential)** - Flexible authentication using the Azure Identity SDK
 11 | 3. **Azure CLI** - Authentication using your Azure CLI login
 12 | 
 13 | ## Method 1: Personal Access Token (PAT) Authentication
 14 | 
 15 | PAT authentication is the simplest method and works well for personal use or testing.
 16 | 
 17 | ### Setup Instructions
 18 | 
 19 | 1. **Generate a PAT in Azure DevOps**:
 20 | 
 21 |    - Go to https://dev.azure.com/{your-organization}/_usersSettings/tokens
 22 |    - Or click on your profile picture > Personal access tokens
 23 |    - Select "+ New Token"
 24 |    - Name your token (e.g., "MCP Server Access")
 25 |    - Set an expiration date
 26 |    - Select the following scopes:
 27 |      - **Code**: Read & Write
 28 |      - **Work Items**: Read & Write
 29 |      - **Build**: Read & Execute
 30 |      - **Project and Team**: Read
 31 |      - **Graph**: Read
 32 |      - **Release**: Read & Execute
 33 |    - Click "Create" and copy the generated token
 34 | 
 35 | 2. **Configure your `.env` file**:
 36 |    ```
 37 |    AZURE_DEVOPS_AUTH_METHOD=pat
 38 |    AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
 39 |    AZURE_DEVOPS_PAT=your-personal-access-token
 40 |    AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project
 41 |    ```
 42 | 
 43 | ### Security Considerations
 44 | 
 45 | - PATs have an expiration date and will need to be renewed
 46 | - Store your PAT securely and never commit it to source control
 47 | - Consider using environment variables or a secrets manager in production
 48 | - Scope your PAT to only the permissions needed for your use case
 49 | 
 50 | ## Method 2: Azure Identity Authentication (DefaultAzureCredential)
 51 | 
 52 | Azure Identity authentication uses the `DefaultAzureCredential` class from the `@azure/identity` package, which provides a simplified authentication experience by trying multiple credential types in sequence.
 53 | 
 54 | ### How DefaultAzureCredential Works
 55 | 
 56 | `DefaultAzureCredential` tries the following credential types in order:
 57 | 
 58 | 1. Environment variables (EnvironmentCredential)
 59 | 2. Managed Identity (ManagedIdentityCredential)
 60 | 3. Azure CLI (AzureCliCredential)
 61 | 4. Visual Studio Code (VisualStudioCodeCredential)
 62 | 5. Azure PowerShell (AzurePowerShellCredential)
 63 | 6. Interactive Browser (InteractiveBrowserCredential) - optional, disabled by default
 64 | 
 65 | This makes it ideal for applications that need to work in different environments (local development, Azure-hosted) without code changes.
 66 | 
 67 | ### Setup Instructions
 68 | 
 69 | 1. **Install the Azure Identity SDK**:
 70 |    The SDK is already included as a dependency in the Azure DevOps MCP Server.
 71 | 
 72 | 2. **Configure your `.env` file**:
 73 | 
 74 |    ```
 75 |    AZURE_DEVOPS_AUTH_METHOD=azure-identity
 76 |    AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
 77 |    AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project
 78 |    ```
 79 | 
 80 | 3. **Set up credentials based on your environment**:
 81 | 
 82 |    a. **For service principals (client credentials)**:
 83 | 
 84 |    ```
 85 |    AZURE_TENANT_ID=your-tenant-id
 86 |    AZURE_CLIENT_ID=your-client-id
 87 |    AZURE_CLIENT_SECRET=your-client-secret
 88 |    ```
 89 | 
 90 |    b. **For managed identities in Azure**:
 91 |    No additional configuration needed if running in Azure with a managed identity.
 92 | 
 93 |    c. **For local development**:
 94 | 
 95 |    - Log in with Azure CLI: `az login`
 96 |    - Or use Visual Studio Code Azure Account extension
 97 | 
 98 | ### Security Considerations
 99 | 
100 | - Use managed identities in Azure for improved security
101 | - For service principals, rotate client secrets regularly
102 | - Store credentials securely using Azure Key Vault or environment variables
103 | - Apply the principle of least privilege when assigning roles
104 | 
105 | ## Method 3: Azure CLI Authentication
106 | 
107 | Azure CLI authentication uses the `AzureCliCredential` class from the `@azure/identity` package, which authenticates using the Azure CLI's logged-in account.
108 | 
109 | ### Setup Instructions
110 | 
111 | 1. **Install the Azure CLI**:
112 | 
113 |    - Follow the instructions at https://docs.microsoft.com/cli/azure/install-azure-cli
114 | 
115 | 2. **Log in to Azure**:
116 | 
117 |    ```bash
118 |    az login
119 |    ```
120 | 
121 | 3. **Configure your `.env` file**:
122 |    ```
123 |    AZURE_DEVOPS_AUTH_METHOD=azure-cli
124 |    AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
125 |    AZURE_DEVOPS_DEFAULT_PROJECT=your-default-project
126 |    ```
127 | 
128 | ### Security Considerations
129 | 
130 | - Azure CLI authentication is best for local development
131 | - Ensure your Azure CLI session is kept secure
132 | - Log out when not in use: `az logout`
133 | 
134 | ## Configuration Reference
135 | 
136 | | Variable                       | Description                                                                        | Required                     | Default          |
137 | | ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------- | ---------------- |
138 | | `AZURE_DEVOPS_AUTH_METHOD`     | Authentication method (`pat`, `azure-identity`, or `azure-cli`) - case-insensitive | No                           | `azure-identity` |
139 | | `AZURE_DEVOPS_ORG_URL`         | Full URL to your Azure DevOps organization                                         | Yes                          | -                |
140 | | `AZURE_DEVOPS_PAT`             | Personal Access Token (for PAT auth)                                               | Only with PAT auth           | -                |
141 | | `AZURE_DEVOPS_DEFAULT_PROJECT` | Default project if none specified                                                  | No                           | -                |
142 | | `AZURE_DEVOPS_API_VERSION`     | API version to use                                                                 | No                           | Latest           |
143 | | `AZURE_TENANT_ID`              | Azure AD tenant ID (for service principals)                                        | Only with service principals | -                |
144 | | `AZURE_CLIENT_ID`              | Azure AD application ID (for service principals)                                   | Only with service principals | -                |
145 | | `AZURE_CLIENT_SECRET`          | Azure AD client secret (for service principals)                                    | Only with service principals | -                |
146 | | `LOG_LEVEL`                    | Logging level (debug, info, warn, error)                                           | No                           | info             |
147 | 
148 | ## Troubleshooting Authentication Issues
149 | 
150 | ### PAT Authentication Issues
151 | 
152 | 1. **Invalid PAT**: Ensure your PAT hasn't expired and has the required scopes
153 | 
154 |    - Error: `TF400813: The user 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' is not authorized to access this resource.`
155 |    - Solution: Generate a new PAT with the correct scopes
156 | 
157 | 2. **Scope issues**: If receiving 403 errors, check if your PAT has the necessary permissions
158 | 
159 |    - Error: `TF401027: You need the Git 'Read' permission to perform this action.`
160 |    - Solution: Update your PAT with the required scopes
161 | 
162 | 3. **Organization access**: Verify your PAT has access to the organization specified in the URL
163 |    - Error: `TF400813: Resource not found for anonymous request.`
164 |    - Solution: Ensure your PAT has access to the specified organization
165 | 
166 | ### Azure Identity Authentication Issues
167 | 
168 | 1. **Missing credentials**: Ensure you have the necessary credentials configured
169 | 
170 |    - Error: `CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token`
171 |    - Solution: Check that you're logged in with Azure CLI or have environment variables set
172 | 
173 | 2. **Permission issues**: Verify your identity has the necessary permissions
174 | 
175 |    - Error: `AuthorizationFailed: The client does not have authorization to perform action`
176 |    - Solution: Assign the appropriate roles to your identity
177 | 
178 | 3. **Token acquisition errors**: Check network connectivity and Azure AD endpoint availability
179 |    - Error: `ClientAuthError: Interaction required`
180 |    - Solution: Check network connectivity or use a different credential type
181 | 
182 | ### Azure CLI Authentication Issues
183 | 
184 | 1. **CLI not installed**: Ensure Azure CLI is installed and in your PATH
185 | 
186 |    - Error: `AzureCliCredential authentication failed: Azure CLI not found`
187 |    - Solution: Install Azure CLI
188 | 
189 | 2. **Not logged in**: Verify you're logged in to Azure CLI
190 | 
191 |    - Error: `AzureCliCredential authentication failed: Please run 'az login'`
192 |    - Solution: Run `az login`
193 | 
194 | 3. **Permission issues**: Check if your Azure CLI account has access to Azure DevOps
195 |    - Error: `TF400813: The user is not authorized to access this resource`
196 |    - Solution: Log in with an account that has access to Azure DevOps
197 | 
198 | ## Best Practices
199 | 
200 | 1. **Choose the right authentication method for your environment**:
201 | 
202 |    - For local development: Azure CLI or PAT
203 |    - For CI/CD pipelines: PAT or service principal
204 |    - For Azure-hosted applications: Managed Identity
205 | 
206 | 2. **Follow the principle of least privilege**:
207 | 
208 |    - Only grant the permissions needed for your use case
209 |    - Regularly review and rotate credentials
210 | 
211 | 3. **Secure your credentials**:
212 | 
213 |    - Use environment variables or a secrets manager
214 |    - Never commit credentials to source control
215 |    - Set appropriate expiration dates for PATs
216 | 
217 | 4. **Monitor and audit authentication**:
218 |    - Review Azure DevOps access logs
219 |    - Set up alerts for suspicious activity
220 | 
221 | ## Examples
222 | 
223 | ### Example 1: Local Development with PAT
224 | 
225 | ```bash
226 | # .env file
227 | AZURE_DEVOPS_AUTH_METHOD=pat
228 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
229 | AZURE_DEVOPS_PAT=abcdefghijklmnopqrstuvwxyz0123456789
230 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
231 | ```
232 | 
233 | ### Example 2: Azure-hosted Application with Managed Identity
234 | 
235 | ```bash
236 | # .env file
237 | AZURE_DEVOPS_AUTH_METHOD=azure-identity
238 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
239 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
240 | ```
241 | 
242 | ### Example 3: CI/CD Pipeline with Service Principal
243 | 
244 | ```bash
245 | # .env file
246 | AZURE_DEVOPS_AUTH_METHOD=azure-identity
247 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
248 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
249 | AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000
250 | AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111
251 | AZURE_CLIENT_SECRET=your-client-secret
252 | ```
253 | 
254 | ### Example 4: Local Development with Azure CLI
255 | 
256 | ```bash
257 | # .env file
258 | AZURE_DEVOPS_AUTH_METHOD=azure-cli
259 | AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
260 | AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
261 | ```
262 | 
```

--------------------------------------------------------------------------------
/src/features/pipelines/download-pipeline-artifact/feature.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import axios from 'axios';
  2 | import JSZip from 'jszip';
  3 | import { WebApi } from 'azure-devops-node-api';
  4 | import { BuildArtifact } from 'azure-devops-node-api/interfaces/BuildInterfaces';
  5 | import { GetArtifactExpandOptions } from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
  6 | import {
  7 |   AzureDevOpsAuthenticationError,
  8 |   AzureDevOpsError,
  9 |   AzureDevOpsResourceNotFoundError,
 10 | } from '../../../shared/errors';
 11 | import { defaultProject } from '../../../utils/environment';
 12 | import { parseArtifactContainer } from '../artifacts';
 13 | import { resolvePipelineId } from '../helpers';
 14 | import {
 15 |   DownloadPipelineArtifactOptions,
 16 |   PipelineArtifactContent,
 17 | } from '../types';
 18 | 
 19 | function normalizeArtifactPath(artifactPath: string): {
 20 |   artifactName: string;
 21 |   relativePath: string;
 22 | } {
 23 |   const trimmed = artifactPath.trim();
 24 |   if (trimmed.length === 0) {
 25 |     throw new AzureDevOpsResourceNotFoundError(
 26 |       'Artifact path must include the artifact name and file path.',
 27 |     );
 28 |   }
 29 | 
 30 |   const sanitized = trimmed.replace(/^[\\/]+/, '').replace(/[\\/]+$/, '');
 31 |   const segments = sanitized
 32 |     .split(/[\\/]+/)
 33 |     .filter((segment) => segment.length > 0);
 34 | 
 35 |   const artifactName = segments.shift();
 36 |   if (!artifactName) {
 37 |     throw new AzureDevOpsResourceNotFoundError(
 38 |       'Artifact path must include the artifact name and file path.',
 39 |     );
 40 |   }
 41 | 
 42 |   if (segments.length === 0) {
 43 |     throw new AzureDevOpsResourceNotFoundError(
 44 |       'Please specify a file path inside the artifact (e.g. <artifact>/<path/to/file>).',
 45 |     );
 46 |   }
 47 | 
 48 |   return {
 49 |     artifactName,
 50 |     relativePath: segments.join('/'),
 51 |   };
 52 | }
 53 | 
 54 | function joinPaths(...parts: Array<string | undefined>): string {
 55 |   return parts
 56 |     .filter(
 57 |       (part): part is string => typeof part === 'string' && part.length > 0,
 58 |     )
 59 |     .map((part) => part.replace(/^[\\/]+|[\\/]+$/g, ''))
 60 |     .filter((part) => part.length > 0)
 61 |     .join('/');
 62 | }
 63 | 
 64 | function buildContainerPathCandidates(
 65 |   artifactName: string,
 66 |   rootPath: string | undefined,
 67 |   relativePath: string,
 68 | ): string[] {
 69 |   const normalizedRelative = relativePath.replace(/^[\\/]+/, '');
 70 |   const candidates = new Set<string>();
 71 | 
 72 |   candidates.add(normalizedRelative);
 73 |   candidates.add(joinPaths(rootPath, normalizedRelative));
 74 |   candidates.add(joinPaths(artifactName, normalizedRelative));
 75 |   candidates.add(joinPaths(rootPath, artifactName, normalizedRelative));
 76 |   candidates.add(joinPaths(artifactName, rootPath, normalizedRelative));
 77 | 
 78 |   return Array.from(candidates).filter((candidate) => candidate.length > 0);
 79 | }
 80 | 
 81 | function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
 82 |   return new Promise((resolve, reject) => {
 83 |     const chunks: Buffer[] = [];
 84 |     stream.on('data', (chunk) => {
 85 |       chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
 86 |     });
 87 |     stream.on('error', reject);
 88 |     stream.on('end', () => {
 89 |       resolve(Buffer.concat(chunks));
 90 |     });
 91 |   });
 92 | }
 93 | 
 94 | async function getContainerItemStream(
 95 |   connection: WebApi,
 96 |   containerId: number,
 97 |   projectId: string,
 98 |   candidatePaths: string[],
 99 | ): Promise<{ stream: NodeJS.ReadableStream; path: string } | null> {
100 |   if (typeof connection.getFileContainerApi !== 'function') {
101 |     return null;
102 |   }
103 | 
104 |   const fileContainerApi = await connection.getFileContainerApi();
105 |   if (!fileContainerApi || typeof fileContainerApi.getItem !== 'function') {
106 |     return null;
107 |   }
108 | 
109 |   const scopeCandidates = [projectId, undefined].filter(
110 |     (scope, index, array) => array.indexOf(scope) === index,
111 |   );
112 | 
113 |   for (const candidatePath of candidatePaths) {
114 |     for (const scope of scopeCandidates) {
115 |       try {
116 |         const response = await fileContainerApi.getItem(
117 |           containerId,
118 |           scope,
119 |           candidatePath,
120 |         );
121 | 
122 |         if (response.statusCode === 404) {
123 |           continue;
124 |         }
125 | 
126 |         if (response.result) {
127 |           return { stream: response.result, path: candidatePath };
128 |         }
129 |       } catch (error) {
130 |         const message = error instanceof Error ? error.message : String(error);
131 |         if (/\b403\b|forbidden|access/i.test(message)) {
132 |           throw new AzureDevOpsAuthenticationError(
133 |             `Failed to access container ${containerId}: ${message}`,
134 |           );
135 |         }
136 |         // Ignore other errors and try the next variation; the container API
137 |         // returns 400 for invalid paths, which we treat as a miss.
138 |       }
139 |     }
140 |   }
141 | 
142 |   return null;
143 | }
144 | 
145 | function escapeRegExp(value: string): string {
146 |   return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
147 | }
148 | 
149 | function normalizeZipPath(path: string): string {
150 |   return path.replace(/^[\\/]+|[\\/]+$/g, '').replace(/\\+/g, '/');
151 | }
152 | 
153 | function selectZipEntry(
154 |   zip: JSZip,
155 |   relativePath: string,
156 |   artifactName: string,
157 |   rootPath?: string,
158 | ): JSZip.JSZipObject | null {
159 |   const normalized = normalizeZipPath(relativePath);
160 |   const candidates = [normalized];
161 | 
162 |   if (artifactName) {
163 |     candidates.push(`${artifactName}/${normalized}`);
164 |   }
165 | 
166 |   if (rootPath) {
167 |     candidates.push(`${rootPath}/${normalized}`);
168 |     if (artifactName) {
169 |       candidates.push(`${artifactName}/${rootPath}/${normalized}`);
170 |     }
171 |   }
172 | 
173 |   for (const candidate of candidates) {
174 |     const match = zip.file(candidate);
175 |     if (!match) {
176 |       continue;
177 |     }
178 | 
179 |     const files = Array.isArray(match) ? match : [match];
180 |     const file = files.find((entry) => !entry.dir);
181 |     if (file) {
182 |       return file;
183 |     }
184 |   }
185 | 
186 |   const fallbackMatches = zip
187 |     .file(new RegExp(`${escapeRegExp(normalized)}$`))
188 |     ?.filter((entry) => !entry.dir);
189 | 
190 |   if (fallbackMatches && fallbackMatches.length > 0) {
191 |     fallbackMatches.sort((a, b) => a.name.length - b.name.length);
192 |     return fallbackMatches[0] ?? null;
193 |   }
194 | 
195 |   return null;
196 | }
197 | 
198 | async function downloadFromContainer(
199 |   connection: WebApi,
200 |   projectId: string,
201 |   artifactName: string,
202 |   artifact: BuildArtifact,
203 |   relativePath: string,
204 | ): Promise<PipelineArtifactContent | null> {
205 |   const { containerId, rootPath } = parseArtifactContainer(artifact.resource);
206 |   if (typeof containerId !== 'number') {
207 |     return null;
208 |   }
209 |   const pathCandidates = buildContainerPathCandidates(
210 |     artifactName,
211 |     rootPath,
212 |     relativePath,
213 |   );
214 | 
215 |   const resolved = await getContainerItemStream(
216 |     connection,
217 |     containerId,
218 |     projectId,
219 |     pathCandidates,
220 |   );
221 | 
222 |   if (!resolved) {
223 |     throw new AzureDevOpsResourceNotFoundError(
224 |       `File ${relativePath} not found in artifact ${artifactName}.`,
225 |     );
226 |   }
227 | 
228 |   const buffer = await streamToBuffer(resolved.stream);
229 |   return {
230 |     artifact: artifactName,
231 |     path: resolved.path,
232 |     content: buffer.toString('utf8'),
233 |   };
234 | }
235 | 
236 | async function downloadFromPipelineArtifact(
237 |   connection: WebApi,
238 |   projectId: string,
239 |   runId: number,
240 |   artifactName: string,
241 |   artifact: BuildArtifact,
242 |   relativePath: string,
243 |   pipelineId?: number,
244 | ): Promise<PipelineArtifactContent> {
245 |   const resolvedPipelineId = await resolvePipelineId(
246 |     connection,
247 |     projectId,
248 |     runId,
249 |     pipelineId,
250 |   );
251 | 
252 |   if (typeof resolvedPipelineId !== 'number') {
253 |     throw new AzureDevOpsResourceNotFoundError(
254 |       `Unable to resolve pipeline identifier for artifact ${artifactName}.`,
255 |     );
256 |   }
257 | 
258 |   const pipelinesApi = await connection.getPipelinesApi();
259 |   const artifactDetails = await pipelinesApi.getArtifact(
260 |     projectId,
261 |     resolvedPipelineId,
262 |     runId,
263 |     artifactName,
264 |     GetArtifactExpandOptions.SignedContent,
265 |   );
266 | 
267 |   const downloadUrl =
268 |     artifactDetails?.signedContent?.url ||
269 |     artifact.resource?.downloadUrl ||
270 |     artifactDetails?.url;
271 | 
272 |   if (!downloadUrl) {
273 |     throw new AzureDevOpsResourceNotFoundError(
274 |       `Artifact ${artifactName} does not expose downloadable content.`,
275 |     );
276 |   }
277 | 
278 |   const response = await axios.get<ArrayBuffer>(downloadUrl, {
279 |     responseType: 'arraybuffer',
280 |   });
281 | 
282 |   const zip = await JSZip.loadAsync(response.data);
283 |   const file = selectZipEntry(
284 |     zip,
285 |     relativePath,
286 |     artifactName,
287 |     parseArtifactContainer(artifact.resource).rootPath,
288 |   );
289 | 
290 |   if (!file) {
291 |     throw new AzureDevOpsResourceNotFoundError(
292 |       `File ${relativePath} not found in artifact ${artifactName}.`,
293 |     );
294 |   }
295 | 
296 |   const content = await file.async('string');
297 |   return {
298 |     artifact: artifactName,
299 |     path: file.name,
300 |     content,
301 |   };
302 | }
303 | 
304 | export async function downloadPipelineArtifact(
305 |   connection: WebApi,
306 |   options: DownloadPipelineArtifactOptions,
307 | ): Promise<PipelineArtifactContent> {
308 |   try {
309 |     const projectId = options.projectId ?? defaultProject;
310 |     const runId = options.runId;
311 |     const { artifactName, relativePath } = normalizeArtifactPath(
312 |       options.artifactPath,
313 |     );
314 | 
315 |     const buildApi = await connection.getBuildApi();
316 | 
317 |     let artifacts: BuildArtifact[];
318 |     try {
319 |       artifacts = await buildApi.getArtifacts(projectId, runId);
320 |     } catch (error) {
321 |       throw new AzureDevOpsResourceNotFoundError(
322 |         `Pipeline run ${runId} not found in project ${projectId}: ${String(error)}`,
323 |       );
324 |     }
325 | 
326 |     const artifact = artifacts.find((item) => item.name === artifactName);
327 |     if (!artifact) {
328 |       throw new AzureDevOpsResourceNotFoundError(
329 |         `Artifact ${artifactName} not found for run ${runId} in project ${projectId}.`,
330 |       );
331 |     }
332 | 
333 |     const containerResult = await downloadFromContainer(
334 |       connection,
335 |       projectId,
336 |       artifactName,
337 |       artifact,
338 |       relativePath,
339 |     );
340 | 
341 |     if (containerResult) {
342 |       return containerResult;
343 |     }
344 | 
345 |     return await downloadFromPipelineArtifact(
346 |       connection,
347 |       projectId,
348 |       runId,
349 |       artifactName,
350 |       artifact,
351 |       relativePath,
352 |       options.pipelineId,
353 |     );
354 |   } catch (error) {
355 |     if (error instanceof AzureDevOpsError) {
356 |       throw error;
357 |     }
358 | 
359 |     if (error instanceof Error) {
360 |       const message = error.message.toLowerCase();
361 |       if (
362 |         message.includes('authentication') ||
363 |         message.includes('unauthorized') ||
364 |         message.includes('401')
365 |       ) {
366 |         throw new AzureDevOpsAuthenticationError(
367 |           `Failed to authenticate: ${error.message}`,
368 |         );
369 |       }
370 | 
371 |       if (
372 |         message.includes('not found') ||
373 |         message.includes('does not exist') ||
374 |         message.includes('404')
375 |       ) {
376 |         throw new AzureDevOpsResourceNotFoundError(
377 |           `Pipeline artifact or project not found: ${error.message}`,
378 |         );
379 |       }
380 |     }
381 | 
382 |     throw new AzureDevOpsError(
383 |       `Failed to download pipeline artifact: ${
384 |         error instanceof Error ? error.message : String(error)
385 |       }`,
386 |     );
387 |   }
388 | }
389 | 
```

--------------------------------------------------------------------------------
/src/features/pull-requests/index.spec.unit.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { WebApi } from 'azure-devops-node-api';
  2 | import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
  3 | import { isPullRequestsRequest, handlePullRequestsRequest } from './index';
  4 | import { createPullRequest } from './create-pull-request';
  5 | import { listPullRequests } from './list-pull-requests';
  6 | import { getPullRequestComments } from './get-pull-request-comments';
  7 | import { addPullRequestComment } from './add-pull-request-comment';
  8 | import { AddPullRequestCommentSchema } from './schemas';
  9 | import { getPullRequestChanges } from './get-pull-request-changes';
 10 | import { getPullRequestChecks } from './get-pull-request-checks';
 11 | 
 12 | // Mock the imported modules
 13 | jest.mock('./create-pull-request', () => ({
 14 |   createPullRequest: jest.fn(),
 15 | }));
 16 | 
 17 | jest.mock('./list-pull-requests', () => ({
 18 |   listPullRequests: jest.fn(),
 19 | }));
 20 | 
 21 | jest.mock('./get-pull-request-comments', () => ({
 22 |   getPullRequestComments: jest.fn(),
 23 | }));
 24 | 
 25 | jest.mock('./add-pull-request-comment', () => ({
 26 |   addPullRequestComment: jest.fn(),
 27 | }));
 28 | 
 29 | jest.mock('./get-pull-request-changes', () => ({
 30 |   getPullRequestChanges: jest.fn(),
 31 | }));
 32 | 
 33 | jest.mock('./get-pull-request-checks', () => ({
 34 |   getPullRequestChecks: jest.fn(),
 35 | }));
 36 | 
 37 | describe('Pull Requests Request Handlers', () => {
 38 |   const mockConnection = {} as WebApi;
 39 | 
 40 |   describe('isPullRequestsRequest', () => {
 41 |     it('should return true for pull requests tools', () => {
 42 |       const validTools = [
 43 |         'create_pull_request',
 44 |         'list_pull_requests',
 45 |         'get_pull_request_comments',
 46 |         'add_pull_request_comment',
 47 |         'get_pull_request_changes',
 48 |         'get_pull_request_checks',
 49 |       ];
 50 |       validTools.forEach((tool) => {
 51 |         const request = {
 52 |           params: { name: tool, arguments: {} },
 53 |           method: 'tools/call',
 54 |         } as CallToolRequest;
 55 |         expect(isPullRequestsRequest(request)).toBe(true);
 56 |       });
 57 |     });
 58 | 
 59 |     it('should return false for non-pull requests tools', () => {
 60 |       const request = {
 61 |         params: { name: 'list_projects', arguments: {} },
 62 |         method: 'tools/call',
 63 |       } as CallToolRequest;
 64 |       expect(isPullRequestsRequest(request)).toBe(false);
 65 |     });
 66 |   });
 67 | 
 68 |   describe('handlePullRequestsRequest', () => {
 69 |     it('should handle create_pull_request request', async () => {
 70 |       const mockPullRequest = { id: 1, title: 'Test PR' };
 71 |       (createPullRequest as jest.Mock).mockResolvedValue(mockPullRequest);
 72 | 
 73 |       const request = {
 74 |         params: {
 75 |           name: 'create_pull_request',
 76 |           arguments: {
 77 |             repositoryId: 'test-repo',
 78 |             title: 'Test PR',
 79 |             sourceRefName: 'refs/heads/feature',
 80 |             targetRefName: 'refs/heads/main',
 81 |             tags: ['Tag-One'],
 82 |           },
 83 |         },
 84 |         method: 'tools/call',
 85 |       } as CallToolRequest;
 86 | 
 87 |       const response = await handlePullRequestsRequest(mockConnection, request);
 88 |       expect(response.content).toHaveLength(1);
 89 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
 90 |         mockPullRequest,
 91 |       );
 92 |       expect(createPullRequest).toHaveBeenCalledWith(
 93 |         mockConnection,
 94 |         expect.any(String),
 95 |         'test-repo',
 96 |         expect.objectContaining({
 97 |           title: 'Test PR',
 98 |           sourceRefName: 'refs/heads/feature',
 99 |           targetRefName: 'refs/heads/main',
100 |           tags: ['Tag-One'],
101 |         }),
102 |       );
103 |     });
104 | 
105 |     it('should handle list_pull_requests request', async () => {
106 |       const mockPullRequests = {
107 |         count: 2,
108 |         value: [
109 |           { id: 1, title: 'PR 1' },
110 |           { id: 2, title: 'PR 2' },
111 |         ],
112 |         hasMoreResults: false,
113 |       };
114 |       (listPullRequests as jest.Mock).mockResolvedValue(mockPullRequests);
115 | 
116 |       const request = {
117 |         params: {
118 |           name: 'list_pull_requests',
119 |           arguments: {
120 |             repositoryId: 'test-repo',
121 |             status: 'active',
122 |           },
123 |         },
124 |         method: 'tools/call',
125 |       } as CallToolRequest;
126 | 
127 |       const response = await handlePullRequestsRequest(mockConnection, request);
128 |       expect(response.content).toHaveLength(1);
129 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
130 |         mockPullRequests,
131 |       );
132 |       expect(listPullRequests).toHaveBeenCalledWith(
133 |         mockConnection,
134 |         expect.any(String),
135 |         'test-repo',
136 |         expect.objectContaining({
137 |           status: 'active',
138 |           pullRequestId: undefined,
139 |         }),
140 |       );
141 |     });
142 | 
143 |     it('should pass pullRequestId to list_pull_requests request', async () => {
144 |       const mockPullRequests = {
145 |         count: 1,
146 |         value: [{ id: 42, title: 'PR 42' }],
147 |         hasMoreResults: false,
148 |       };
149 |       (listPullRequests as jest.Mock).mockResolvedValue(mockPullRequests);
150 | 
151 |       const request = {
152 |         params: {
153 |           name: 'list_pull_requests',
154 |           arguments: {
155 |             repositoryId: 'test-repo',
156 |             pullRequestId: 42,
157 |           },
158 |         },
159 |         method: 'tools/call',
160 |       } as CallToolRequest;
161 | 
162 |       const response = await handlePullRequestsRequest(mockConnection, request);
163 |       expect(response.content).toHaveLength(1);
164 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
165 |         mockPullRequests,
166 |       );
167 |       expect(listPullRequests).toHaveBeenCalledWith(
168 |         mockConnection,
169 |         expect.any(String),
170 |         'test-repo',
171 |         expect.objectContaining({
172 |           pullRequestId: 42,
173 |         }),
174 |       );
175 |     });
176 | 
177 |     it('should handle get_pull_request_comments request', async () => {
178 |       const mockComments = {
179 |         threads: [
180 |           {
181 |             id: 1,
182 |             comments: [{ id: 1, content: 'Comment 1' }],
183 |           },
184 |         ],
185 |       };
186 |       (getPullRequestComments as jest.Mock).mockResolvedValue(mockComments);
187 | 
188 |       const request = {
189 |         params: {
190 |           name: 'get_pull_request_comments',
191 |           arguments: {
192 |             repositoryId: 'test-repo',
193 |             pullRequestId: 123,
194 |           },
195 |         },
196 |         method: 'tools/call',
197 |       } as CallToolRequest;
198 | 
199 |       const response = await handlePullRequestsRequest(mockConnection, request);
200 |       expect(response.content).toHaveLength(1);
201 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
202 |         mockComments,
203 |       );
204 |       expect(getPullRequestComments).toHaveBeenCalledWith(
205 |         mockConnection,
206 |         expect.any(String),
207 |         'test-repo',
208 |         123,
209 |         expect.objectContaining({
210 |           pullRequestId: 123,
211 |         }),
212 |       );
213 |     });
214 | 
215 |     it('should handle add_pull_request_comment request', async () => {
216 |       const mockResult = {
217 |         comment: { id: 1, content: 'New comment' },
218 |         thread: { id: 1 },
219 |       };
220 |       (addPullRequestComment as jest.Mock).mockResolvedValue(mockResult);
221 | 
222 |       const request = {
223 |         params: {
224 |           name: 'add_pull_request_comment',
225 |           arguments: {
226 |             repositoryId: 'test-repo',
227 |             pullRequestId: 123,
228 |             content: 'New comment',
229 |             status: 'active', // Status is required when creating a new thread
230 |           },
231 |         },
232 |         method: 'tools/call',
233 |       } as CallToolRequest;
234 | 
235 |       // Mock the schema parsing
236 |       const mockParsedArgs = {
237 |         repositoryId: 'test-repo',
238 |         pullRequestId: 123,
239 |         content: 'New comment',
240 |         status: 'active',
241 |       };
242 | 
243 |       // Use a different approach for mocking
244 |       const originalParse = AddPullRequestCommentSchema.parse;
245 |       AddPullRequestCommentSchema.parse = jest
246 |         .fn()
247 |         .mockReturnValue(mockParsedArgs);
248 | 
249 |       const response = await handlePullRequestsRequest(mockConnection, request);
250 |       expect(response.content).toHaveLength(1);
251 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
252 |         mockResult,
253 |       );
254 |       expect(addPullRequestComment).toHaveBeenCalledWith(
255 |         mockConnection,
256 |         expect.any(String),
257 |         'test-repo',
258 |         123,
259 |         expect.objectContaining({
260 |           content: 'New comment',
261 |         }),
262 |       );
263 | 
264 |       // Restore the original parse function
265 |       AddPullRequestCommentSchema.parse = originalParse;
266 |     });
267 | 
268 |     it('should handle get_pull_request_changes request', async () => {
269 |       const mockResult = { changes: { changeEntries: [] }, evaluations: [] };
270 |       (getPullRequestChanges as jest.Mock).mockResolvedValue(mockResult);
271 | 
272 |       const request = {
273 |         params: {
274 |           name: 'get_pull_request_changes',
275 |           arguments: { repositoryId: 'test-repo', pullRequestId: 1 },
276 |         },
277 |         method: 'tools/call',
278 |       } as CallToolRequest;
279 | 
280 |       const response = await handlePullRequestsRequest(mockConnection, request);
281 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
282 |         mockResult,
283 |       );
284 |       expect(getPullRequestChanges).toHaveBeenCalled();
285 |     });
286 | 
287 |     it('should handle get_pull_request_checks request', async () => {
288 |       const mockResult = { statuses: [], policyEvaluations: [] };
289 |       (getPullRequestChecks as jest.Mock).mockResolvedValue(mockResult);
290 | 
291 |       const request = {
292 |         params: {
293 |           name: 'get_pull_request_checks',
294 |           arguments: { repositoryId: 'test-repo', pullRequestId: 7 },
295 |         },
296 |         method: 'tools/call',
297 |       } as CallToolRequest;
298 | 
299 |       const response = await handlePullRequestsRequest(mockConnection, request);
300 | 
301 |       expect(JSON.parse(response.content[0].text as string)).toEqual(
302 |         mockResult,
303 |       );
304 |       expect(getPullRequestChecks).toHaveBeenCalledWith(
305 |         mockConnection,
306 |         expect.objectContaining({
307 |           repositoryId: 'test-repo',
308 |           pullRequestId: 7,
309 |         }),
310 |       );
311 |     });
312 | 
313 |     it('should throw error for unknown tool', async () => {
314 |       const request = {
315 |         params: {
316 |           name: 'unknown_tool',
317 |           arguments: {},
318 |         },
319 |         method: 'tools/call',
320 |       } as CallToolRequest;
321 | 
322 |       await expect(
323 |         handlePullRequestsRequest(mockConnection, request),
324 |       ).rejects.toThrow('Unknown pull requests tool');
325 |     });
326 | 
327 |     it('should propagate errors from pull request functions', async () => {
328 |       const mockError = new Error('Test error');
329 |       (listPullRequests as jest.Mock).mockRejectedValue(mockError);
330 | 
331 |       const request = {
332 |         params: {
333 |           name: 'list_pull_requests',
334 |           arguments: {
335 |             repositoryId: 'test-repo',
336 |           },
337 |         },
338 |         method: 'tools/call',
339 |       } as CallToolRequest;
340 | 
341 |       await expect(
342 |         handlePullRequestsRequest(mockConnection, request),
343 |       ).rejects.toThrow(mockError);
344 |     });
345 |   });
346 | });
347 | 
```

--------------------------------------------------------------------------------
/src/features/pipelines/artifacts.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import axios from 'axios';
  2 | import JSZip from 'jszip';
  3 | import { WebApi } from 'azure-devops-node-api';
  4 | import {
  5 |   BuildArtifact,
  6 |   ArtifactResource,
  7 | } from 'azure-devops-node-api/interfaces/BuildInterfaces';
  8 | import {
  9 |   ContainerItemType,
 10 |   FileContainerItem,
 11 | } from 'azure-devops-node-api/interfaces/FileContainerInterfaces';
 12 | import { GetArtifactExpandOptions } from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
 13 | import { PipelineArtifactItem, PipelineRunArtifact } from './types';
 14 | 
 15 | interface ArtifactContainerInfo {
 16 |   containerId?: number;
 17 |   rootPath?: string;
 18 | }
 19 | 
 20 | const MAX_ITEMS_PER_ARTIFACT = 200;
 21 | 
 22 | function extractContainerInfo(
 23 |   resource?: ArtifactResource,
 24 | ): ArtifactContainerInfo {
 25 |   const data = resource?.data;
 26 |   if (typeof data !== 'string' || data.length === 0) {
 27 |     return {};
 28 |   }
 29 | 
 30 |   const segments = data.split('/').filter((segment) => segment.length > 0);
 31 |   if (segments.length < 2) {
 32 |     return {};
 33 |   }
 34 | 
 35 |   const containerId = Number.parseInt(segments[1] ?? '', 10);
 36 |   if (Number.isNaN(containerId)) {
 37 |     return {};
 38 |   }
 39 | 
 40 |   const rootPath = segments.slice(2).join('/');
 41 | 
 42 |   return {
 43 |     containerId,
 44 |     rootPath: rootPath.length > 0 ? rootPath : undefined,
 45 |   };
 46 | }
 47 | 
 48 | function mapBuildArtifact(artifact: BuildArtifact): PipelineRunArtifact {
 49 |   const resource = artifact.resource;
 50 |   const { containerId, rootPath } = extractContainerInfo(resource);
 51 | 
 52 |   return {
 53 |     name: artifact.name ?? 'unknown',
 54 |     type: resource?.type,
 55 |     source: artifact.source,
 56 |     downloadUrl: resource?.downloadUrl,
 57 |     resourceUrl: resource?.url,
 58 |     containerId,
 59 |     rootPath,
 60 |   };
 61 | }
 62 | 
 63 | function normalizePathSegment(segment: string): string {
 64 |   return segment.replace(/^[\\/]+|[\\/]+$/g, '');
 65 | }
 66 | 
 67 | function normalizeFullPath(path: string): string {
 68 |   return path.replace(/\\+/g, '/').replace(/^\/+/, '');
 69 | }
 70 | 
 71 | function makeRelativePath(path: string, prefixes: string[]): string {
 72 |   const normalized = normalizeFullPath(path);
 73 |   const filteredPrefixes = prefixes
 74 |     .map((prefix) => normalizePathSegment(prefix))
 75 |     .filter((prefix) => prefix.length > 0)
 76 |     .sort((a, b) => b.length - a.length);
 77 | 
 78 |   for (const prefix of filteredPrefixes) {
 79 |     if (normalized === prefix) {
 80 |       return '';
 81 |     }
 82 |     if (normalized.startsWith(`${prefix}/`)) {
 83 |       return normalized.slice(prefix.length + 1);
 84 |     }
 85 |   }
 86 | 
 87 |   return normalized;
 88 | }
 89 | 
 90 | function mapContainerItems(
 91 |   items: FileContainerItem[],
 92 |   artifact: PipelineRunArtifact,
 93 | ): { items: PipelineArtifactItem[]; truncated: boolean } {
 94 |   const basePrefixes = [artifact.rootPath, artifact.name].filter(
 95 |     (value): value is string => typeof value === 'string' && value.length > 0,
 96 |   );
 97 | 
 98 |   const uniquePaths = new Set<string>();
 99 |   const mapped: PipelineArtifactItem[] = [];
100 |   let truncated = false;
101 | 
102 |   for (const item of items) {
103 |     const relative = makeRelativePath(item.path, basePrefixes);
104 |     if (relative.length === 0) {
105 |       continue;
106 |     }
107 | 
108 |     if (uniquePaths.has(relative)) {
109 |       continue;
110 |     }
111 |     uniquePaths.add(relative);
112 | 
113 |     mapped.push({
114 |       path: relative,
115 |       itemType: item.itemType === ContainerItemType.Folder ? 'folder' : 'file',
116 |       size: item.fileLength,
117 |     });
118 | 
119 |     if (mapped.length >= MAX_ITEMS_PER_ARTIFACT) {
120 |       truncated = true;
121 |       break;
122 |     }
123 |   }
124 | 
125 |   mapped.sort((a, b) => a.path.localeCompare(b.path));
126 | 
127 |   return {
128 |     items: mapped,
129 |     truncated,
130 |   };
131 | }
132 | 
133 | async function listContainerItems(
134 |   connection: WebApi,
135 |   projectId: string,
136 |   artifact: PipelineRunArtifact,
137 | ): Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }> {
138 |   if (typeof artifact.containerId !== 'number') {
139 |     return {};
140 |   }
141 | 
142 |   const fileContainerApi =
143 |     typeof connection.getFileContainerApi === 'function'
144 |       ? await connection.getFileContainerApi()
145 |       : null;
146 | 
147 |   if (!fileContainerApi || typeof fileContainerApi.getItems !== 'function') {
148 |     return {};
149 |   }
150 | 
151 |   const scopeCandidates = [projectId, undefined].filter(
152 |     (scope, index, array) => array.indexOf(scope) === index,
153 |   );
154 | 
155 |   const itemPathCandidates = [
156 |     artifact.rootPath,
157 |     artifact.name,
158 |     undefined,
159 |   ].filter((value, index, arr) => arr.indexOf(value) === index);
160 | 
161 |   for (const scope of scopeCandidates) {
162 |     for (const itemPath of itemPathCandidates) {
163 |       try {
164 |         const items = await fileContainerApi.getItems(
165 |           artifact.containerId,
166 |           scope,
167 |           typeof itemPath === 'string' && itemPath.length > 0
168 |             ? itemPath
169 |             : undefined,
170 |         );
171 | 
172 |         if (!Array.isArray(items) || items.length === 0) {
173 |           continue;
174 |         }
175 | 
176 |         const { items: mapped, truncated } = mapContainerItems(items, artifact);
177 |         if (mapped.length === 0) {
178 |           continue;
179 |         }
180 | 
181 |         return {
182 |           items: mapped,
183 |           truncated,
184 |         };
185 |       } catch {
186 |         // Swallow and try next combination.
187 |       }
188 |     }
189 |   }
190 | 
191 |   return {};
192 | }
193 | 
194 | async function listPipelineArtifactItems(
195 |   artifact: PipelineRunArtifact,
196 | ): Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }> {
197 |   const downloadUrl =
198 |     artifact.signedContentUrl || artifact.downloadUrl || artifact.resourceUrl;
199 | 
200 |   if (!downloadUrl) {
201 |     return {};
202 |   }
203 | 
204 |   try {
205 |     const response = await axios.get<ArrayBuffer>(downloadUrl, {
206 |       responseType: 'arraybuffer',
207 |     });
208 | 
209 |     const zip = await JSZip.loadAsync(response.data);
210 |     const basePrefixes = [artifact.name, artifact.rootPath].filter(
211 |       (value): value is string => typeof value === 'string' && value.length > 0,
212 |     );
213 | 
214 |     const items: PipelineArtifactItem[] = [];
215 |     const directories = new Set<string>();
216 | 
217 |     let hitLimit = false;
218 | 
219 |     zip.forEach((entryPath, entry) => {
220 |       if (hitLimit) {
221 |         return;
222 |       }
223 | 
224 |       const relative = makeRelativePath(entryPath, basePrefixes);
225 |       if (relative.length === 0) {
226 |         return;
227 |       }
228 | 
229 |       if (entry.dir) {
230 |         const folderPath = relative.replace(/\/+$/, '');
231 |         if (folderPath.length > 0) {
232 |           directories.add(folderPath);
233 |         }
234 |         return;
235 |       }
236 | 
237 |       // Ensure parent folders are recorded even when the archive omits explicit entries
238 |       const segments = relative.split('/');
239 |       if (segments.length > 1) {
240 |         for (let i = 1; i < segments.length; i += 1) {
241 |           const folder = segments.slice(0, i).join('/');
242 |           directories.add(folder);
243 |         }
244 |       }
245 | 
246 |       items.push({
247 |         path: relative,
248 |         itemType: 'file',
249 |       });
250 | 
251 |       if (items.length >= MAX_ITEMS_PER_ARTIFACT) {
252 |         hitLimit = true;
253 |       }
254 |     });
255 | 
256 |     const folderItems: PipelineArtifactItem[] = Array.from(directories)
257 |       .filter((folder) => folder.length > 0)
258 |       .map((folder) => ({ path: folder, itemType: 'folder' }));
259 | 
260 |     const combined = [...folderItems, ...items]
261 |       .filter((entry, index, array) => {
262 |         const duplicateIndex = array.findIndex(
263 |           (candidate) => candidate.path === entry.path,
264 |         );
265 |         return duplicateIndex === index;
266 |       })
267 |       .sort((a, b) => a.path.localeCompare(b.path));
268 | 
269 |     const truncated = hitLimit || combined.length > MAX_ITEMS_PER_ARTIFACT;
270 |     return {
271 |       items: truncated ? combined.slice(0, MAX_ITEMS_PER_ARTIFACT) : combined,
272 |       truncated,
273 |     };
274 |   } catch {
275 |     return {};
276 |   }
277 | }
278 | 
279 | export async function fetchRunArtifacts(
280 |   connection: WebApi,
281 |   projectId: string,
282 |   runId: number,
283 |   pipelineId?: number,
284 | ): Promise<PipelineRunArtifact[]> {
285 |   try {
286 |     const buildApi = await connection.getBuildApi();
287 |     if (!buildApi || typeof buildApi.getArtifacts !== 'function') {
288 |       return [];
289 |     }
290 | 
291 |     const artifacts = await buildApi.getArtifacts(projectId, runId);
292 |     if (!artifacts || artifacts.length === 0) {
293 |       return [];
294 |     }
295 | 
296 |     const summaries = artifacts.map(mapBuildArtifact);
297 | 
298 |     if (typeof pipelineId === 'number') {
299 |       const pipelinesApi = await connection.getPipelinesApi();
300 |       await Promise.all(
301 |         summaries.map(async (summary) => {
302 |           try {
303 |             const artifactDetails = await pipelinesApi.getArtifact(
304 |               projectId,
305 |               pipelineId,
306 |               runId,
307 |               summary.name,
308 |               GetArtifactExpandOptions.SignedContent,
309 |             );
310 | 
311 |             const signedContentUrl = artifactDetails?.signedContent?.url;
312 |             if (signedContentUrl) {
313 |               summary.signedContentUrl = signedContentUrl;
314 |             }
315 |           } catch {
316 |             // Ignore failures fetching signed content; best-effort enrichment.
317 |           }
318 |         }),
319 |       );
320 |     }
321 | 
322 |     const enriched = await Promise.all(
323 |       summaries.map(async (artifact) => {
324 |         const collectors: Array<
325 |           Promise<{ items?: PipelineArtifactItem[]; truncated?: boolean }>
326 |         > = [];
327 | 
328 |         const artifactType = artifact.type?.toLowerCase();
329 | 
330 |         if (
331 |           artifactType === 'container' ||
332 |           typeof artifact.containerId === 'number'
333 |         ) {
334 |           collectors.push(listContainerItems(connection, projectId, artifact));
335 |         }
336 | 
337 |         if (artifactType?.includes('pipelineartifact')) {
338 |           collectors.push(listPipelineArtifactItems(artifact));
339 |         }
340 | 
341 |         if (collectors.length === 0) {
342 |           return artifact;
343 |         }
344 | 
345 |         let aggregatedItems: PipelineArtifactItem[] | undefined;
346 |         let truncated = false;
347 | 
348 |         for (const collector of collectors) {
349 |           try {
350 |             const result = await collector;
351 |             if (!result.items || result.items.length === 0) {
352 |               continue;
353 |             }
354 | 
355 |             aggregatedItems = aggregatedItems
356 |               ? [...aggregatedItems, ...result.items]
357 |               : result.items;
358 |             truncated = truncated || Boolean(result.truncated);
359 |           } catch {
360 |             // Continue to next collector
361 |           }
362 |         }
363 | 
364 |         if (!aggregatedItems || aggregatedItems.length === 0) {
365 |           return artifact;
366 |         }
367 | 
368 |         const uniqueItems = Array.from(
369 |           new Map(aggregatedItems.map((item) => [item.path, item])).values(),
370 |         ).sort((a, b) => a.path.localeCompare(b.path));
371 | 
372 |         return {
373 |           ...artifact,
374 |           items:
375 |             uniqueItems.length > MAX_ITEMS_PER_ARTIFACT
376 |               ? uniqueItems.slice(0, MAX_ITEMS_PER_ARTIFACT)
377 |               : uniqueItems,
378 |           itemsTruncated:
379 |             truncated ||
380 |             uniqueItems.length > MAX_ITEMS_PER_ARTIFACT ||
381 |             undefined,
382 |         };
383 |       }),
384 |     );
385 | 
386 |     return enriched;
387 |   } catch {
388 |     return [];
389 |   }
390 | }
391 | 
392 | export function getArtifactContainerInfo(
393 |   artifact: PipelineRunArtifact,
394 | ): ArtifactContainerInfo {
395 |   return {
396 |     containerId: artifact.containerId,
397 |     rootPath: artifact.rootPath,
398 |   };
399 | }
400 | 
401 | export function parseArtifactContainer(
402 |   resource?: ArtifactResource,
403 | ): ArtifactContainerInfo {
404 |   return extractContainerInfo(resource);
405 | }
406 | 
```

--------------------------------------------------------------------------------
/.github/skills/skill-creator/scripts/init_skill.py:
--------------------------------------------------------------------------------

```python
  1 | #!/usr/bin/env python3
  2 | """
  3 | Skill Initializer - Creates a new skill from template
  4 | 
  5 | Usage:
  6 |     init_skill.py <skill-name> --path <path>
  7 | 
  8 | Examples:
  9 |     init_skill.py my-new-skill --path skills/public
 10 |     init_skill.py my-api-helper --path skills/private
 11 |     init_skill.py custom-skill --path /custom/location
 12 | """
 13 | 
 14 | import sys
 15 | from pathlib import Path
 16 | 
 17 | 
 18 | SKILL_TEMPLATE = """---
 19 | name: {skill_name}
 20 | description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.]
 21 | ---
 22 | 
 23 | # {skill_title}
 24 | 
 25 | ## Overview
 26 | 
 27 | [TODO: 1-2 sentences explaining what this skill enables]
 28 | 
 29 | ## Structuring This Skill
 30 | 
 31 | [TODO: Choose the structure that best fits this skill's purpose. Common patterns:
 32 | 
 33 | **1. Workflow-Based** (best for sequential processes)
 34 | - Works well when there are clear step-by-step procedures
 35 | - Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing"
 36 | - Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2...
 37 | 
 38 | **2. Task-Based** (best for tool collections)
 39 | - Works well when the skill offers different operations/capabilities
 40 | - Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text"
 41 | - Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2...
 42 | 
 43 | **3. Reference/Guidelines** (best for standards or specifications)
 44 | - Works well for brand guidelines, coding standards, or requirements
 45 | - Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features"
 46 | - Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage...
 47 | 
 48 | **4. Capabilities-Based** (best for integrated systems)
 49 | - Works well when the skill provides multiple interrelated features
 50 | - Example: Product Management with "Core Capabilities" → numbered capability list
 51 | - Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature...
 52 | 
 53 | Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations).
 54 | 
 55 | Delete this entire "Structuring This Skill" section when done - it's just guidance.]
 56 | 
 57 | ## [TODO: Replace with the first main section based on chosen structure]
 58 | 
 59 | [TODO: Add content here. See examples in existing skills:
 60 | - Code samples for technical skills
 61 | - Decision trees for complex workflows
 62 | - Concrete examples with realistic user requests
 63 | - References to scripts/templates/references as needed]
 64 | 
 65 | ## Resources
 66 | 
 67 | This skill includes example resource directories that demonstrate how to organize different types of bundled resources:
 68 | 
 69 | ### scripts/
 70 | Executable code (Python/Bash/etc.) that can be run directly to perform specific operations.
 71 | 
 72 | **Examples from other skills:**
 73 | - PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation
 74 | - DOCX skill: `document.py`, `utilities.py` - Python modules for document processing
 75 | 
 76 | **Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations.
 77 | 
 78 | **Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments.
 79 | 
 80 | ### references/
 81 | Documentation and reference material intended to be loaded into context to inform Claude's process and thinking.
 82 | 
 83 | **Examples from other skills:**
 84 | - Product management: `communication.md`, `context_building.md` - detailed workflow guides
 85 | - BigQuery: API reference documentation and query examples
 86 | - Finance: Schema documentation, company policies
 87 | 
 88 | **Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working.
 89 | 
 90 | ### assets/
 91 | Files not intended to be loaded into context, but rather used within the output Claude produces.
 92 | 
 93 | **Examples from other skills:**
 94 | - Brand styling: PowerPoint template files (.pptx), logo files
 95 | - Frontend builder: HTML/React boilerplate project directories
 96 | - Typography: Font files (.ttf, .woff2)
 97 | 
 98 | **Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output.
 99 | 
100 | ---
101 | 
102 | **Any unneeded directories can be deleted.** Not every skill requires all three types of resources.
103 | """
104 | 
105 | EXAMPLE_SCRIPT = '''#!/usr/bin/env python3
106 | """
107 | Example helper script for {skill_name}
108 | 
109 | This is a placeholder script that can be executed directly.
110 | Replace with actual implementation or delete if not needed.
111 | 
112 | Example real scripts from other skills:
113 | - pdf/scripts/fill_fillable_fields.py - Fills PDF form fields
114 | - pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images
115 | """
116 | 
117 | def main():
118 |     print("This is an example script for {skill_name}")
119 |     # TODO: Add actual script logic here
120 |     # This could be data processing, file conversion, API calls, etc.
121 | 
122 | if __name__ == "__main__":
123 |     main()
124 | '''
125 | 
126 | EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title}
127 | 
128 | This is a placeholder for detailed reference documentation.
129 | Replace with actual reference content or delete if not needed.
130 | 
131 | Example real reference docs from other skills:
132 | - product-management/references/communication.md - Comprehensive guide for status updates
133 | - product-management/references/context_building.md - Deep-dive on gathering context
134 | - bigquery/references/ - API references and query examples
135 | 
136 | ## When Reference Docs Are Useful
137 | 
138 | Reference docs are ideal for:
139 | - Comprehensive API documentation
140 | - Detailed workflow guides
141 | - Complex multi-step processes
142 | - Information too lengthy for main SKILL.md
143 | - Content that's only needed for specific use cases
144 | 
145 | ## Structure Suggestions
146 | 
147 | ### API Reference Example
148 | - Overview
149 | - Authentication
150 | - Endpoints with examples
151 | - Error codes
152 | - Rate limits
153 | 
154 | ### Workflow Guide Example
155 | - Prerequisites
156 | - Step-by-step instructions
157 | - Common patterns
158 | - Troubleshooting
159 | - Best practices
160 | """
161 | 
162 | EXAMPLE_ASSET = """# Example Asset File
163 | 
164 | This placeholder represents where asset files would be stored.
165 | Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed.
166 | 
167 | Asset files are NOT intended to be loaded into context, but rather used within
168 | the output Claude produces.
169 | 
170 | Example asset files from other skills:
171 | - Brand guidelines: logo.png, slides_template.pptx
172 | - Frontend builder: hello-world/ directory with HTML/React boilerplate
173 | - Typography: custom-font.ttf, font-family.woff2
174 | - Data: sample_data.csv, test_dataset.json
175 | 
176 | ## Common Asset Types
177 | 
178 | - Templates: .pptx, .docx, boilerplate directories
179 | - Images: .png, .jpg, .svg, .gif
180 | - Fonts: .ttf, .otf, .woff, .woff2
181 | - Boilerplate code: Project directories, starter files
182 | - Icons: .ico, .svg
183 | - Data files: .csv, .json, .xml, .yaml
184 | 
185 | Note: This is a text placeholder. Actual assets can be any file type.
186 | """
187 | 
188 | 
189 | def title_case_skill_name(skill_name):
190 |     """Convert hyphenated skill name to Title Case for display."""
191 |     return ' '.join(word.capitalize() for word in skill_name.split('-'))
192 | 
193 | 
194 | def init_skill(skill_name, path):
195 |     """
196 |     Initialize a new skill directory with template SKILL.md.
197 | 
198 |     Args:
199 |         skill_name: Name of the skill
200 |         path: Path where the skill directory should be created
201 | 
202 |     Returns:
203 |         Path to created skill directory, or None if error
204 |     """
205 |     # Determine skill directory path
206 |     skill_dir = Path(path).resolve() / skill_name
207 | 
208 |     # Check if directory already exists
209 |     if skill_dir.exists():
210 |         print(f"❌ Error: Skill directory already exists: {skill_dir}")
211 |         return None
212 | 
213 |     # Create skill directory
214 |     try:
215 |         skill_dir.mkdir(parents=True, exist_ok=False)
216 |         print(f"✅ Created skill directory: {skill_dir}")
217 |     except Exception as e:
218 |         print(f"❌ Error creating directory: {e}")
219 |         return None
220 | 
221 |     # Create SKILL.md from template
222 |     skill_title = title_case_skill_name(skill_name)
223 |     skill_content = SKILL_TEMPLATE.format(
224 |         skill_name=skill_name,
225 |         skill_title=skill_title
226 |     )
227 | 
228 |     skill_md_path = skill_dir / 'SKILL.md'
229 |     try:
230 |         skill_md_path.write_text(skill_content)
231 |         print("✅ Created SKILL.md")
232 |     except Exception as e:
233 |         print(f"❌ Error creating SKILL.md: {e}")
234 |         return None
235 | 
236 |     # Create resource directories with example files
237 |     try:
238 |         # Create scripts/ directory with example script
239 |         scripts_dir = skill_dir / 'scripts'
240 |         scripts_dir.mkdir(exist_ok=True)
241 |         example_script = scripts_dir / 'example.py'
242 |         example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name))
243 |         example_script.chmod(0o755)
244 |         print("✅ Created scripts/example.py")
245 | 
246 |         # Create references/ directory with example reference doc
247 |         references_dir = skill_dir / 'references'
248 |         references_dir.mkdir(exist_ok=True)
249 |         example_reference = references_dir / 'api_reference.md'
250 |         example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title))
251 |         print("✅ Created references/api_reference.md")
252 | 
253 |         # Create assets/ directory with example asset placeholder
254 |         assets_dir = skill_dir / 'assets'
255 |         assets_dir.mkdir(exist_ok=True)
256 |         example_asset = assets_dir / 'example_asset.txt'
257 |         example_asset.write_text(EXAMPLE_ASSET)
258 |         print("✅ Created assets/example_asset.txt")
259 |     except Exception as e:
260 |         print(f"❌ Error creating resource directories: {e}")
261 |         return None
262 | 
263 |     # Print next steps
264 |     print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}")
265 |     print("\nNext steps:")
266 |     print("1. Edit SKILL.md to complete the TODO items and update the description")
267 |     print("2. Customize or delete the example files in scripts/, references/, and assets/")
268 |     print("3. Run the validator when ready to check the skill structure")
269 | 
270 |     return skill_dir
271 | 
272 | 
273 | def main():
274 |     if len(sys.argv) < 4 or sys.argv[2] != '--path':
275 |         print("Usage: init_skill.py <skill-name> --path <path>")
276 |         print("\nSkill name requirements:")
277 |         print("  - Hyphen-case identifier (e.g., 'data-analyzer')")
278 |         print("  - Lowercase letters, digits, and hyphens only")
279 |         print("  - Max 40 characters")
280 |         print("  - Must match directory name exactly")
281 |         print("\nExamples:")
282 |         print("  init_skill.py my-new-skill --path skills/public")
283 |         print("  init_skill.py my-api-helper --path skills/private")
284 |         print("  init_skill.py custom-skill --path /custom/location")
285 |         sys.exit(1)
286 | 
287 |     skill_name = sys.argv[1]
288 |     path = sys.argv[3]
289 | 
290 |     print(f"🚀 Initializing skill: {skill_name}")
291 |     print(f"   Location: {path}")
292 |     print()
293 | 
294 |     result = init_skill(skill_name, path)
295 | 
296 |     if result:
297 |         sys.exit(0)
298 |     else:
299 |         sys.exit(1)
300 | 
301 | 
302 | if __name__ == "__main__":
303 |     main()
304 | 
```
Page 6/10FirstPrevNextLast