This is page 3 of 7. Use http://codebase.md/tiberriver256/azure-devops-mcp?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/pipeline-timeline/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
TimelineRecord,
TimelineRecordState,
TaskResult,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
AzureDevOpsAuthenticationError,
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
} from '../../../shared/errors';
import { defaultProject } from '../../../utils/environment';
import { GetPipelineTimelineOptions, PipelineTimeline } from '../types';
const API_VERSION = '7.1';
export async function getPipelineTimeline(
connection: WebApi,
options: GetPipelineTimelineOptions,
): Promise<PipelineTimeline> {
try {
const buildApi = await connection.getBuildApi();
const projectId = options.projectId ?? defaultProject;
const { runId, timelineId, state, result } = options;
const route = `${encodeURIComponent(projectId)}/_apis/build/builds/${runId}/timeline`;
const baseUrl = connection.serverUrl.replace(/\/+$/, '');
const url = new URL(`${route}`, `${baseUrl}/`);
url.searchParams.set('api-version', API_VERSION);
if (timelineId) {
url.searchParams.set('timelineId', timelineId);
}
const requestOptions = buildApi.createRequestOptions(
'application/json',
API_VERSION,
);
const response = await buildApi.rest.get<PipelineTimeline | null>(
url.toString(),
requestOptions,
);
if (response.statusCode === 404 || !response.result) {
throw new AzureDevOpsResourceNotFoundError(
`Timeline not found for run ${runId} in project ${projectId}`,
);
}
const timeline = response.result as PipelineTimeline & {
records?: TimelineRecord[];
};
const stateFilters = normalizeFilter(state);
const resultFilters = normalizeFilter(result);
if (Array.isArray(timeline.records) && (stateFilters || resultFilters)) {
const filteredRecords = timeline.records.filter((record) => {
const recordState = stateToString(record.state);
const recordResult = resultToString(record.result);
const stateMatch =
!stateFilters || (recordState && stateFilters.has(recordState));
const resultMatch =
!resultFilters || (recordResult && resultFilters.has(recordResult));
return stateMatch && resultMatch;
});
return {
...timeline,
records: filteredRecords,
} as PipelineTimeline;
}
return timeline;
} catch (error) {
if (error instanceof AzureDevOpsError) {
throw error;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (
message.includes('authentication') ||
message.includes('unauthorized') ||
message.includes('401')
) {
throw new AzureDevOpsAuthenticationError(
`Failed to authenticate: ${error.message}`,
);
}
if (
message.includes('not found') ||
message.includes('does not exist') ||
message.includes('404')
) {
throw new AzureDevOpsResourceNotFoundError(
`Pipeline timeline or project not found: ${error.message}`,
);
}
}
throw new AzureDevOpsError(
`Failed to retrieve pipeline timeline: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
function normalizeFilter(value?: string | string[]): Set<string> | undefined {
if (!value) {
return undefined;
}
const values = Array.isArray(value) ? value : [value];
const normalized = values
.map((item) => (typeof item === 'string' ? item.trim().toLowerCase() : ''))
.filter((item) => item.length > 0);
return normalized.length > 0 ? new Set(normalized) : undefined;
}
function stateToString(
state?: TimelineRecordState | string,
): string | undefined {
if (typeof state === 'number') {
const stateName = TimelineRecordState[state];
return typeof stateName === 'string' ? stateName.toLowerCase() : undefined;
}
if (typeof state === 'string' && state.length > 0) {
return state.toLowerCase();
}
return undefined;
}
function resultToString(result?: TaskResult | string): string | undefined {
if (typeof result === 'number') {
const resultName = TaskResult[result];
return typeof resultName === 'string'
? resultName.toLowerCase()
: undefined;
}
if (typeof result === 'string' && result.length > 0) {
return result.toLowerCase();
}
return undefined;
}
```
--------------------------------------------------------------------------------
/src/features/wikis/index.ts:
--------------------------------------------------------------------------------
```typescript
export { getWikis, GetWikisSchema } from './get-wikis';
export { getWikiPage, GetWikiPageSchema } from './get-wiki-page';
export { createWiki, CreateWikiSchema, WikiType } from './create-wiki';
export { updateWikiPage, UpdateWikiPageSchema } from './update-wiki-page';
export { listWikiPages, ListWikiPagesSchema } from './list-wiki-pages';
export { createWikiPage, CreateWikiPageSchema } from './create-wiki-page';
// Export tool definitions
export * from './tool-definitions';
// New exports for request handling
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { WebApi } from 'azure-devops-node-api';
import {
RequestIdentifier,
RequestHandler,
} from '../../shared/types/request-handler';
import { defaultProject, defaultOrg } from '../../utils/environment';
import {
GetWikisSchema,
GetWikiPageSchema,
CreateWikiSchema,
UpdateWikiPageSchema,
ListWikiPagesSchema,
CreateWikiPageSchema,
getWikis,
getWikiPage,
createWiki,
updateWikiPage,
listWikiPages,
createWikiPage,
} from './';
/**
* Checks if the request is for the wikis feature
*/
export const isWikisRequest: RequestIdentifier = (
request: CallToolRequest,
): boolean => {
const toolName = request.params.name;
return [
'get_wikis',
'get_wiki_page',
'create_wiki',
'update_wiki_page',
'list_wiki_pages',
'create_wiki_page',
].includes(toolName);
};
/**
* Handles wikis feature requests
*/
export const handleWikisRequest: RequestHandler = async (
connection: WebApi,
request: CallToolRequest,
): Promise<{ content: Array<{ type: string; text: string }> }> => {
switch (request.params.name) {
case 'get_wikis': {
const args = GetWikisSchema.parse(request.params.arguments);
const result = await getWikis(connection, {
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_wiki_page': {
const args = GetWikiPageSchema.parse(request.params.arguments);
const result = await getWikiPage({
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
wikiId: args.wikiId,
pagePath: args.pagePath,
});
return {
content: [{ type: 'text', text: result }],
};
}
case 'create_wiki': {
const args = CreateWikiSchema.parse(request.params.arguments);
const result = await createWiki(connection, {
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
name: args.name,
type: args.type,
repositoryId: args.repositoryId ?? undefined,
mappedPath: args.mappedPath ?? undefined,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'update_wiki_page': {
const args = UpdateWikiPageSchema.parse(request.params.arguments);
const result = await updateWikiPage({
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
wikiId: args.wikiId,
pagePath: args.pagePath,
content: args.content,
comment: args.comment,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'list_wiki_pages': {
const args = ListWikiPagesSchema.parse(request.params.arguments);
const result = await listWikiPages({
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
wikiId: args.wikiId,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'create_wiki_page': {
const args = CreateWikiPageSchema.parse(request.params.arguments);
const result = await createWikiPage({
organizationId: args.organizationId ?? defaultOrg,
projectId: args.projectId ?? defaultProject,
wikiId: args.wikiId,
pagePath: args.pagePath,
content: args.content,
comment: args.comment,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
default:
throw new Error(`Unknown wikis tool: ${request.params.name}`);
}
};
```
--------------------------------------------------------------------------------
/src/features/repositories/get-file-content/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
GitVersionDescriptor,
GitItem,
GitVersionType,
VersionControlRecursionType,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors';
/**
* Response format for file content
*/
export interface FileContentResponse {
content: string;
isDirectory: boolean;
}
/**
* Get content of a file or directory from a repository
*
* @param connection - Azure DevOps WebApi connection
* @param projectId - Project ID or name
* @param repositoryId - Repository ID or name
* @param path - Path to file or directory
* @param versionDescriptor - Optional version descriptor for retrieving file at specific commit/branch/tag
* @returns Content of the file or list of items if path is a directory
*/
export async function getFileContent(
connection: WebApi,
projectId: string,
repositoryId: string,
path: string = '/',
versionDescriptor?: { versionType: GitVersionType; version: string },
): Promise<FileContentResponse> {
try {
const gitApi = await connection.getGitApi();
// Create version descriptor for API requests
const gitVersionDescriptor: GitVersionDescriptor | undefined =
versionDescriptor
? {
version: versionDescriptor.version,
versionType: versionDescriptor.versionType,
versionOptions: undefined,
}
: undefined;
// First, try to get items using the path to determine if it's a directory
let isDirectory = false;
let items: GitItem[] = [];
try {
items = await gitApi.getItems(
repositoryId,
projectId,
path,
VersionControlRecursionType.OneLevel,
undefined,
undefined,
undefined,
undefined,
gitVersionDescriptor,
);
// If multiple items are returned or the path ends with /, it's a directory
isDirectory = items.length > 1 || (path !== '/' && path.endsWith('/'));
} catch {
// If getItems fails, try to get file content directly
isDirectory = false;
}
if (isDirectory) {
// For directories, return a formatted list of the items
return {
content: JSON.stringify(items, null, 2),
isDirectory: true,
};
} else {
// For files, get the actual content
try {
// Get file content using the Git API
const contentStream = await gitApi.getItemContent(
repositoryId,
path,
projectId,
undefined,
undefined,
undefined,
undefined,
false,
gitVersionDescriptor,
true,
);
// Convert the stream to a string
if (contentStream) {
const chunks: Buffer[] = [];
// Listen for data events to collect chunks
contentStream.on('data', (chunk) => {
chunks.push(Buffer.from(chunk));
});
// Use a promise to wait for the stream to finish
const content = await new Promise<string>((resolve, reject) => {
contentStream.on('end', () => {
// Concatenate all chunks and convert to string
const buffer = Buffer.concat(chunks);
resolve(buffer.toString('utf8'));
});
contentStream.on('error', (err) => {
reject(err);
});
});
return {
content,
isDirectory: false,
};
}
throw new Error('No content returned from API');
} catch (error) {
// If it's a 404 or similar error, throw a ResourceNotFoundError
if (
error instanceof Error &&
(error.message.includes('not found') ||
error.message.includes('does not exist'))
) {
throw new AzureDevOpsResourceNotFoundError(
`Path '${path}' not found in repository '${repositoryId}' of project '${projectId}'`,
);
}
throw error;
}
}
} catch (error) {
// If it's already an AzureDevOpsResourceNotFoundError, rethrow it
if (error instanceof AzureDevOpsResourceNotFoundError) {
throw error;
}
// Otherwise, wrap it in a ResourceNotFoundError
throw new AzureDevOpsResourceNotFoundError(
`Failed to get content for path '${path}': ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/users/get-me/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import axios from 'axios';
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
import {
AzureDevOpsError,
AzureDevOpsAuthenticationError,
AzureDevOpsValidationError,
} from '../../../shared/errors';
import { UserProfile } from '../types';
/**
* Get details of the currently authenticated user
*
* This function returns basic profile information about the authenticated user.
*
* @param connection The Azure DevOps WebApi connection
* @returns User profile information including id, displayName, and email
* @throws {AzureDevOpsError} If retrieval of user information fails
*/
export async function getMe(connection: WebApi): Promise<UserProfile> {
try {
// Extract organization from the connection URL
const { organization } = extractOrgFromUrl(connection.serverUrl);
// Get the authorization header
const authHeader = await getAuthorizationHeader();
// Make direct call to the Profile API endpoint
// Note: This API is in the vssps.dev.azure.com domain, not dev.azure.com
const response = await axios.get(
`https://vssps.dev.azure.com/${organization}/_apis/profile/profiles/me?api-version=7.1`,
{
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
},
);
const profile = response.data;
// Return the user profile with required fields
return {
id: profile.id,
displayName: profile.displayName || '',
email: profile.emailAddress || '',
};
} catch (error) {
// Handle authentication errors
if (
axios.isAxiosError(error) &&
(error.response?.status === 401 || error.response?.status === 403)
) {
throw new AzureDevOpsAuthenticationError(
`Authentication failed: ${error.message}`,
);
}
// If it's already an AzureDevOpsError, rethrow it
if (error instanceof AzureDevOpsError) {
throw error;
}
// Otherwise, wrap it in a generic error
throw new AzureDevOpsError(
`Failed to get user information: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Extract organization from the Azure DevOps URL
*
* @param url The Azure DevOps URL
* @returns The organization
*/
function extractOrgFromUrl(url: string): { organization: string } {
// First try modern dev.azure.com format
let match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/);
// If not found, try legacy visualstudio.com format
if (!match) {
match = url.match(/https?:\/\/([^.]+)\.visualstudio\.com/);
}
// Fallback: capture the first path segment for any URL
if (!match) {
match = url.match(/https?:\/\/[^/]+\/([^/]+)/);
}
const organization = match ? match[1] : '';
if (!organization) {
throw new AzureDevOpsValidationError(
'Could not extract organization from URL',
);
}
return {
organization,
};
}
/**
* Get the authorization header for API requests
*
* @returns The authorization header
*/
async function getAuthorizationHeader(): Promise<string> {
try {
// For PAT authentication, we can construct the header directly
if (
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' &&
process.env.AZURE_DEVOPS_PAT
) {
// For PAT auth, we can construct the Basic auth header directly
const token = process.env.AZURE_DEVOPS_PAT;
const base64Token = Buffer.from(`:${token}`).toString('base64');
return `Basic ${base64Token}`;
}
// For Azure Identity / Azure CLI auth, we need to get a token
// using the Azure DevOps resource ID
// Choose the appropriate credential based on auth method
const credential =
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
? new AzureCliCredential()
: new DefaultAzureCredential();
// Azure DevOps resource ID for token acquisition
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
// Get token for Azure DevOps
const token = await credential.getToken(
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
);
if (!token || !token.token) {
throw new Error('Failed to acquire token for Azure DevOps');
}
return `Bearer ${token.token}`;
} catch (error) {
throw new AzureDevOpsAuthenticationError(
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/search/search-wiki/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import axios from 'axios';
import { searchWiki } from './feature';
// Mock Azure Identity
jest.mock('@azure/identity', () => {
const mockGetToken = jest.fn().mockResolvedValue({ token: 'mock-token' });
return {
DefaultAzureCredential: jest.fn().mockImplementation(() => ({
getToken: mockGetToken,
})),
AzureCliCredential: jest.fn().mockImplementation(() => ({
getToken: mockGetToken,
})),
};
});
// Mock axios
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('searchWiki unit', () => {
// Mock WebApi connection
const mockConnection = {
_getHttpClient: jest.fn().mockReturnValue({
getAuthorizationHeader: jest.fn().mockReturnValue('Bearer mock-token'),
}),
getCoreApi: jest.fn().mockImplementation(() => ({
getProjects: jest
.fn()
.mockResolvedValue([{ name: 'TestProject', id: 'project-id' }]),
})),
serverUrl: 'https://dev.azure.com/testorg',
} as unknown as WebApi;
beforeEach(() => {
jest.clearAllMocks();
});
test('should return wiki search results with project ID', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 1,
results: [
{
fileName: 'Example Page',
path: '/Example Page',
collection: {
name: 'DefaultCollection',
},
project: {
name: 'TestProject',
id: 'project-id',
},
hits: [
{
content: 'This is an example page',
charOffset: 5,
length: 7,
},
],
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
const result = await searchWiki(mockConnection, {
searchText: 'example',
projectId: 'TestProject',
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(1);
expect(result.results).toHaveLength(1);
expect(result.results[0].fileName).toBe('Example Page');
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining(
'https://almsearch.dev.azure.com/testorg/TestProject/_apis/search/wikisearchresults',
),
expect.objectContaining({
searchText: 'example',
filters: expect.objectContaining({
Project: ['TestProject'],
}),
}),
expect.any(Object),
);
});
test('should perform organization-wide wiki search when projectId is not provided', async () => {
// Arrange
const mockSearchResponse = {
data: {
count: 2,
results: [
{
fileName: 'Example Page 1',
path: '/Example Page 1',
collection: {
name: 'DefaultCollection',
},
project: {
name: 'Project1',
id: 'project-id-1',
},
hits: [
{
content: 'This is an example page',
charOffset: 5,
length: 7,
},
],
},
{
fileName: 'Example Page 2',
path: '/Example Page 2',
collection: {
name: 'DefaultCollection',
},
project: {
name: 'Project2',
id: 'project-id-2',
},
hits: [
{
content: 'This is another example page',
charOffset: 5,
length: 7,
},
],
},
],
},
};
mockedAxios.post.mockResolvedValueOnce(mockSearchResponse);
// Act
const result = await searchWiki(mockConnection, {
searchText: 'example',
});
// Assert
expect(result).toBeDefined();
expect(result.count).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0].project.name).toBe('Project1');
expect(result.results[1].project.name).toBe('Project2');
expect(mockedAxios.post).toHaveBeenCalledTimes(1);
expect(mockedAxios.post).toHaveBeenCalledWith(
expect.stringContaining(
'https://almsearch.dev.azure.com/testorg/_apis/search/wikisearchresults',
),
expect.not.objectContaining({
filters: expect.objectContaining({
Project: expect.anything(),
}),
}),
expect.any(Object),
);
});
});
```
--------------------------------------------------------------------------------
/src/features/pull-requests/add-pull-request-comment/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
Comment,
CommentThreadStatus,
CommentType,
GitPullRequestCommentThread,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import { AzureDevOpsError } from '../../../shared/errors';
import { AddPullRequestCommentOptions, AddCommentResponse } from '../types';
import {
transformCommentThreadStatus,
transformCommentType,
} from '../../../shared/enums';
/**
* Add a comment to a pull request
*
* @param connection The Azure DevOps WebApi connection
* @param projectId The ID or name of the project
* @param repositoryId The ID or name of the repository
* @param pullRequestId The ID of the pull request
* @param options Options for adding the comment
* @returns The created comment or thread
*/
export async function addPullRequestComment(
connection: WebApi,
projectId: string,
repositoryId: string,
pullRequestId: number,
options: AddPullRequestCommentOptions,
): Promise<AddCommentResponse> {
try {
const gitApi = await connection.getGitApi();
// Create comment object
const comment: Comment = {
content: options.content,
commentType: CommentType.Text, // Default to Text type
parentCommentId: options.parentCommentId,
};
// Case 1: Add comment to an existing thread
if (options.threadId) {
const createdComment = await gitApi.createComment(
comment,
repositoryId,
pullRequestId,
options.threadId,
projectId,
);
if (!createdComment) {
throw new Error('Failed to create pull request comment');
}
return {
comment: {
...createdComment,
commentType: transformCommentType(createdComment.commentType),
},
};
}
// Case 2: Create new thread with comment
else {
// Map status string to CommentThreadStatus enum
let threadStatus: CommentThreadStatus | undefined;
if (options.status) {
switch (options.status) {
case 'active':
threadStatus = CommentThreadStatus.Active;
break;
case 'fixed':
threadStatus = CommentThreadStatus.Fixed;
break;
case 'wontFix':
threadStatus = CommentThreadStatus.WontFix;
break;
case 'closed':
threadStatus = CommentThreadStatus.Closed;
break;
case 'pending':
threadStatus = CommentThreadStatus.Pending;
break;
case 'byDesign':
threadStatus = CommentThreadStatus.ByDesign;
break;
case 'unknown':
threadStatus = CommentThreadStatus.Unknown;
break;
}
}
// Create thread with comment
const thread: GitPullRequestCommentThread = {
comments: [comment],
status: threadStatus,
};
// Add file context if specified (file comment)
if (options.filePath) {
thread.threadContext = {
filePath: options.filePath,
// Only add line information if provided
rightFileStart: options.lineNumber
? {
line: options.lineNumber,
offset: 1, // Default to start of line
}
: undefined,
rightFileEnd: options.lineNumber
? {
line: options.lineNumber,
offset: 1, // Default to start of line
}
: undefined,
};
}
const createdThread = await gitApi.createThread(
thread,
repositoryId,
pullRequestId,
projectId,
);
if (
!createdThread ||
!createdThread.comments ||
createdThread.comments.length === 0
) {
throw new Error('Failed to create pull request comment thread');
}
return {
comment: {
...createdThread.comments[0],
commentType: transformCommentType(
createdThread.comments[0].commentType,
),
},
thread: {
...createdThread,
status: transformCommentThreadStatus(createdThread.status),
comments: createdThread.comments?.map((comment) => ({
...comment,
commentType: transformCommentType(comment.commentType),
})),
},
};
}
} catch (error) {
if (error instanceof AzureDevOpsError) {
throw error;
}
throw new Error(
`Failed to add pull request comment: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/work-items/manage-work-item-link/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { manageWorkItemLink } from './feature';
import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors';
describe('manageWorkItemLink', () => {
let mockConnection: any;
let mockWitApi: any;
const projectId = 'test-project';
const sourceWorkItemId = 123;
const targetWorkItemId = 456;
const relationType = 'System.LinkTypes.Related';
const newRelationType = 'System.LinkTypes.Hierarchy-Forward';
const comment = 'Test link comment';
beforeEach(() => {
mockWitApi = {
updateWorkItem: jest.fn(),
};
mockConnection = {
getWorkItemTrackingApi: jest.fn().mockResolvedValue(mockWitApi),
serverUrl: 'https://dev.azure.com/test-org',
};
});
test('should add a work item link', async () => {
// Setup
const updatedWorkItem = {
id: sourceWorkItemId,
fields: { 'System.Title': 'Test' },
};
mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem);
// Execute
const result = await manageWorkItemLink(mockConnection, projectId, {
sourceWorkItemId,
targetWorkItemId,
operation: 'add',
relationType,
comment,
});
// Verify
expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled();
expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith(
{}, // customHeaders
[
{
op: 'add',
path: '/relations/-',
value: {
rel: relationType,
url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`,
attributes: { comment },
},
},
],
sourceWorkItemId,
projectId,
);
expect(result).toEqual(updatedWorkItem);
});
test('should remove a work item link', async () => {
// Setup
const updatedWorkItem = {
id: sourceWorkItemId,
fields: { 'System.Title': 'Test' },
};
mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem);
// Execute
const result = await manageWorkItemLink(mockConnection, projectId, {
sourceWorkItemId,
targetWorkItemId,
operation: 'remove',
relationType,
});
// Verify
expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled();
expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith(
{}, // customHeaders
[
{
op: 'remove',
path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`,
},
],
sourceWorkItemId,
projectId,
);
expect(result).toEqual(updatedWorkItem);
});
test('should update a work item link', async () => {
// Setup
const updatedWorkItem = {
id: sourceWorkItemId,
fields: { 'System.Title': 'Test' },
};
mockWitApi.updateWorkItem.mockResolvedValue(updatedWorkItem);
// Execute
const result = await manageWorkItemLink(mockConnection, projectId, {
sourceWorkItemId,
targetWorkItemId,
operation: 'update',
relationType,
newRelationType,
comment,
});
// Verify
expect(mockConnection.getWorkItemTrackingApi).toHaveBeenCalled();
expect(mockWitApi.updateWorkItem).toHaveBeenCalledWith(
{}, // customHeaders
[
{
op: 'remove',
path: `/relations/+[rel=${relationType};url=${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}]`,
},
{
op: 'add',
path: '/relations/-',
value: {
rel: newRelationType,
url: `${mockConnection.serverUrl}/_apis/wit/workItems/${targetWorkItemId}`,
attributes: { comment },
},
},
],
sourceWorkItemId,
projectId,
);
expect(result).toEqual(updatedWorkItem);
});
test('should throw error when work item not found', async () => {
// Setup
mockWitApi.updateWorkItem.mockResolvedValue(null);
// Execute and verify
await expect(
manageWorkItemLink(mockConnection, projectId, {
sourceWorkItemId,
targetWorkItemId,
operation: 'add',
relationType,
}),
).rejects.toThrow(AzureDevOpsResourceNotFoundError);
});
test('should throw error when update operation missing newRelationType', async () => {
// Execute and verify
await expect(
manageWorkItemLink(mockConnection, projectId, {
sourceWorkItemId,
targetWorkItemId,
operation: 'update',
relationType,
// newRelationType is missing
}),
).rejects.toThrow('New relation type is required for update operation');
});
});
```
--------------------------------------------------------------------------------
/src/features/pull-requests/create-pull-request/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { createPullRequest } from './feature';
import { AzureDevOpsError } from '../../../shared/errors';
describe('createPullRequest unit', () => {
// Test for required fields validation
test('should throw error when title is not provided', async () => {
// Arrange - mock connection, never used due to validation error
const mockConnection: any = {
getGitApi: jest.fn(),
};
// Act & Assert
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: '',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: 'refs/heads/main',
}),
).rejects.toThrow('Title is required');
});
test('should throw error when source branch is not provided', async () => {
// Arrange - mock connection, never used due to validation error
const mockConnection: any = {
getGitApi: jest.fn(),
};
// Act & Assert
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: 'Test PR',
sourceRefName: '',
targetRefName: 'refs/heads/main',
}),
).rejects.toThrow('Source branch is required');
});
test('should throw error when target branch is not provided', async () => {
// Arrange - mock connection, never used due to validation error
const mockConnection: any = {
getGitApi: jest.fn(),
};
// Act & Assert
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: 'Test PR',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: '',
}),
).rejects.toThrow('Target branch is required');
});
// Test for error propagation
test('should propagate custom errors when thrown internally', async () => {
// Arrange
const mockConnection: any = {
getGitApi: jest.fn().mockImplementation(() => {
throw new AzureDevOpsError('Custom error');
}),
};
// Act & Assert
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: 'Test PR',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: 'refs/heads/main',
}),
).rejects.toThrow(AzureDevOpsError);
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: 'Test PR',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: 'refs/heads/main',
}),
).rejects.toThrow('Custom error');
});
test('should wrap unexpected errors in a friendly error message', async () => {
// Arrange
const mockConnection: any = {
getGitApi: jest.fn().mockImplementation(() => {
throw new Error('Unexpected error');
}),
};
// Act & Assert
await expect(
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
title: 'Test PR',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: 'refs/heads/main',
}),
).rejects.toThrow('Failed to create pull request: Unexpected error');
});
test('should apply unique trimmed tags to the pull request', async () => {
const createPullRequestMock = jest.fn().mockResolvedValue({
pullRequestId: 99,
labels: [{ name: 'existing' }],
});
const createPullRequestLabelMock = jest
.fn()
.mockImplementation(async (label: { name: string }) => ({
name: label.name,
}));
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
createPullRequest: createPullRequestMock,
createPullRequestLabel: createPullRequestLabelMock,
}),
};
const result = await createPullRequest(
mockConnection,
'TestProject',
'TestRepo',
{
title: 'Test PR',
sourceRefName: 'refs/heads/feature-branch',
targetRefName: 'refs/heads/main',
tags: ['Tag-One', 'tag-one', ' Tag-Two ', ''],
},
);
expect(createPullRequestMock).toHaveBeenCalledWith(
expect.objectContaining({
labels: [{ name: 'Tag-One' }, { name: 'Tag-Two' }],
}),
'TestRepo',
'TestProject',
);
expect(createPullRequestLabelMock).toHaveBeenCalledTimes(2);
expect(createPullRequestLabelMock).toHaveBeenCalledWith(
{ name: 'Tag-One' },
'TestRepo',
99,
'TestProject',
);
expect(createPullRequestLabelMock).toHaveBeenCalledWith(
{ name: 'Tag-Two' },
'TestRepo',
99,
'TestProject',
);
expect(result.labels).toEqual([
{ name: 'existing' },
{ name: 'Tag-One' },
{ name: 'Tag-Two' },
]);
});
});
```
--------------------------------------------------------------------------------
/src/features/projects/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { isProjectsRequest, handleProjectsRequest } from './index';
import { getProject } from './get-project';
import { getProjectDetails } from './get-project-details';
import { listProjects } from './list-projects';
// Mock the imported modules
jest.mock('./get-project', () => ({
getProject: jest.fn(),
}));
jest.mock('./get-project-details', () => ({
getProjectDetails: jest.fn(),
}));
jest.mock('./list-projects', () => ({
listProjects: jest.fn(),
}));
describe('Projects Request Handlers', () => {
const mockConnection = {} as WebApi;
describe('isProjectsRequest', () => {
it('should return true for projects requests', () => {
const validTools = [
'list_projects',
'get_project',
'get_project_details',
];
validTools.forEach((tool) => {
const request = {
params: { name: tool, arguments: {} },
method: 'tools/call',
} as CallToolRequest;
expect(isProjectsRequest(request)).toBe(true);
});
});
it('should return false for non-projects requests', () => {
const request = {
params: { name: 'list_work_items', arguments: {} },
method: 'tools/call',
} as CallToolRequest;
expect(isProjectsRequest(request)).toBe(false);
});
});
describe('handleProjectsRequest', () => {
it('should handle list_projects request', async () => {
const mockProjects = [
{ id: '1', name: 'Project 1' },
{ id: '2', name: 'Project 2' },
];
(listProjects as jest.Mock).mockResolvedValue(mockProjects);
const request = {
params: {
name: 'list_projects',
arguments: {
top: 10,
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleProjectsRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockProjects,
);
expect(listProjects).toHaveBeenCalledWith(
mockConnection,
expect.objectContaining({
top: 10,
}),
);
});
it('should handle get_project request', async () => {
const mockProject = { id: '1', name: 'Project 1' };
(getProject as jest.Mock).mockResolvedValue(mockProject);
const request = {
params: {
name: 'get_project',
arguments: {
projectId: 'Project 1',
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleProjectsRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockProject,
);
expect(getProject).toHaveBeenCalledWith(mockConnection, 'Project 1');
});
it('should handle get_project_details request', async () => {
const mockProjectDetails = {
id: '1',
name: 'Project 1',
teams: [{ id: 'team1', name: 'Team 1' }],
};
(getProjectDetails as jest.Mock).mockResolvedValue(mockProjectDetails);
const request = {
params: {
name: 'get_project_details',
arguments: {
projectId: 'Project 1',
includeTeams: true,
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleProjectsRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockProjectDetails,
);
expect(getProjectDetails).toHaveBeenCalledWith(
mockConnection,
expect.objectContaining({
projectId: 'Project 1',
includeTeams: true,
}),
);
});
it('should throw error for unknown tool', async () => {
const request = {
params: {
name: 'unknown_tool',
arguments: {},
},
method: 'tools/call',
} as CallToolRequest;
await expect(
handleProjectsRequest(mockConnection, request),
).rejects.toThrow('Unknown projects tool');
});
it('should propagate errors from project functions', async () => {
const mockError = new Error('Test error');
(listProjects as jest.Mock).mockRejectedValue(mockError);
const request = {
params: {
name: 'list_projects',
arguments: {},
},
method: 'tools/call',
} as CallToolRequest;
await expect(
handleProjectsRequest(mockConnection, request),
).rejects.toThrow(mockError);
});
});
});
```
--------------------------------------------------------------------------------
/src/shared/errors/azure-devops-errors.ts:
--------------------------------------------------------------------------------
```typescript
/**
* Base error class for Azure DevOps API errors.
* All specific Azure DevOps errors should extend this class.
*
* @class AzureDevOpsError
* @extends {Error}
*/
export class AzureDevOpsError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'AzureDevOpsError';
}
}
/**
* Error thrown when authentication with Azure DevOps fails.
* This can occur due to invalid credentials, expired tokens, or network issues.
*
* @class AzureDevOpsAuthenticationError
* @extends {AzureDevOpsError}
*/
export class AzureDevOpsAuthenticationError extends AzureDevOpsError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'AzureDevOpsAuthenticationError';
}
}
/**
* Type for API response error details
*/
export type ApiErrorResponse = {
message?: string;
statusCode?: number;
details?: unknown;
[key: string]: unknown;
};
/**
* Error thrown when input validation fails.
* This includes invalid parameters, malformed requests, or missing required fields.
*
* @class AzureDevOpsValidationError
* @extends {AzureDevOpsError}
* @property {ApiErrorResponse} [response] - The raw response from the API containing validation details
*/
export class AzureDevOpsValidationError extends AzureDevOpsError {
response?: ApiErrorResponse;
constructor(
message: string,
response?: ApiErrorResponse,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'AzureDevOpsValidationError';
this.response = response;
}
}
/**
* Error thrown when a requested resource is not found.
* This can occur when trying to access non-existent projects, repositories, or work items.
*
* @class AzureDevOpsResourceNotFoundError
* @extends {AzureDevOpsError}
*/
export class AzureDevOpsResourceNotFoundError extends AzureDevOpsError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'AzureDevOpsResourceNotFoundError';
}
}
/**
* Error thrown when the user lacks permissions for an operation.
* This occurs when trying to access or modify resources without proper authorization.
*
* @class AzureDevOpsPermissionError
* @extends {AzureDevOpsError}
*/
export class AzureDevOpsPermissionError extends AzureDevOpsError {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = 'AzureDevOpsPermissionError';
}
}
/**
* Error thrown when the API rate limit is exceeded.
* Contains information about when the rate limit will reset.
*
* @class AzureDevOpsRateLimitError
* @extends {AzureDevOpsError}
* @property {Date} resetAt - The time when the rate limit will reset
*/
export class AzureDevOpsRateLimitError extends AzureDevOpsError {
resetAt: Date;
constructor(message: string, resetAt: Date, options?: ErrorOptions) {
super(message, options);
this.name = 'AzureDevOpsRateLimitError';
this.resetAt = resetAt;
}
}
/**
* Helper function to check if an error is an Azure DevOps error.
* Useful for type narrowing in catch blocks.
*
* @param {unknown} error - The error to check
* @returns {boolean} True if the error is an Azure DevOps error
*
* @example
* try {
* // Some Azure DevOps operation
* } catch (error) {
* if (isAzureDevOpsError(error)) {
* // Handle Azure DevOps specific error
* } else {
* // Handle other errors
* }
* }
*/
export function isAzureDevOpsError(error: unknown): error is AzureDevOpsError {
return error instanceof AzureDevOpsError;
}
/**
* Format an Azure DevOps error for display.
* Provides a consistent error message format across different error types.
*
* @param {unknown} error - The error to format
* @returns {string} A formatted error message
*
* @example
* try {
* // Some Azure DevOps operation
* } catch (error) {
* console.error(formatAzureDevOpsError(error));
* }
*/
export function formatAzureDevOpsError(error: unknown): string {
// Handle non-error objects
if (error === null) {
return 'null';
}
if (error === undefined) {
return 'undefined';
}
if (typeof error === 'string') {
return error;
}
if (typeof error === 'number' || typeof error === 'boolean') {
return String(error);
}
// Handle error-like objects
const errorObj = error as Record<string, unknown>;
let message = `${errorObj.name || 'Unknown'}: ${errorObj.message || 'Unknown error'}`;
if (error instanceof AzureDevOpsValidationError) {
if (error.response) {
message += `\nResponse: ${JSON.stringify(error.response)}`;
} else {
message += '\nNo response details available';
}
} else if (error instanceof AzureDevOpsRateLimitError) {
message += `\nReset at: ${error.resetAt.toISOString()}`;
}
return message;
}
```
--------------------------------------------------------------------------------
/docs/tools/resources.md:
--------------------------------------------------------------------------------
```markdown
# Azure DevOps Resource URIs
In addition to tools, the Azure DevOps MCP server provides access to resources via standardized URI patterns. Resources allow AI assistants to directly reference and retrieve content from Azure DevOps repositories using simple, predictable URLs.
## Repository Content Resources
The server supports accessing files and directories from Git repositories using the following resource URI patterns.
### Available Resource URI Templates
| Resource Type | URI Template | Description |
| ------------- | ------------ | ----------- |
| Default Branch Content | `ado://{organization}/{project}/{repo}/contents{/path*}` | Access file or directory content from the default branch |
| Branch-Specific Content | `ado://{organization}/{project}/{repo}/branches/{branch}/contents{/path*}` | Access content from a specific branch |
| Commit-Specific Content | `ado://{organization}/{project}/{repo}/commits/{commit}/contents{/path*}` | Access content from a specific commit |
| Tag-Specific Content | `ado://{organization}/{project}/{repo}/tags/{tag}/contents{/path*}` | Access content from a specific tag |
| Pull Request Content | `ado://{organization}/{project}/{repo}/pullrequests/{prId}/contents{/path*}` | Access content from a pull request |
### URI Components
- `{organization}`: Your Azure DevOps organization name
- `{project}`: The project name or ID
- `{repo}`: The repository name or ID
- `{path*}`: The path to the file or directory within the repository (optional)
- `{branch}`: The name of a branch
- `{commit}`: The SHA-1 hash of a commit
- `{tag}`: The name of a tag
- `{prId}`: The ID of a pull request
## Examples
### Accessing Files from the Default Branch
To access the content of a file in the default branch:
```
ado://myorg/MyProject/MyRepo/contents/src/index.ts
```
This retrieves the content of `index.ts` from the `src` directory in the default branch.
### Accessing Directory Content
To list the contents of a directory:
```
ado://myorg/MyProject/MyRepo/contents/src
```
This returns a JSON array containing information about all items in the `src` directory.
### Accessing Content from a Specific Branch
To access content from a feature branch:
```
ado://myorg/MyProject/MyRepo/branches/feature/new-ui/contents/src/index.ts
```
This retrieves the content of `index.ts` from the `feature/new-ui` branch.
### Accessing Content from a Specific Commit
To access content at a specific commit:
```
ado://myorg/MyProject/MyRepo/commits/a1b2c3d4e5f6g7h8i9j0/contents/src/index.ts
```
This retrieves the version of `index.ts` at the specified commit.
### Accessing Content from a Tag
To access content from a tagged release:
```
ado://myorg/MyProject/MyRepo/tags/v1.0.0/contents/README.md
```
This retrieves the README.md file from the v1.0.0 tag.
### Accessing Content from a Pull Request
To access content from a pull request:
```
ado://myorg/MyProject/MyRepo/pullrequests/42/contents/src/index.ts
```
This retrieves the version of `index.ts` from pull request #42.
## Implementation Details
When a resource URI is requested, the server:
1. Parses the URI to extract the components (organization, project, repository, path, etc.)
2. Establishes a connection to Azure DevOps using the configured authentication method
3. Determines if a specific version (branch, commit, tag) is requested
4. Uses the `getFileContent` functionality to retrieve the content
5. Returns the content with the appropriate MIME type
## Response Format
Responses are returned with the appropriate MIME type based on the file extension. For example:
- `.ts`, `.tsx` files: `application/typescript`
- `.js` files: `application/javascript`
- `.json` files: `application/json`
- `.md` files: `text/markdown`
- `.txt` files: `text/plain`
- `.html`, `.htm` files: `text/html`
- Image files (`.png`, `.jpg`, `.gif`, etc.): appropriate image MIME types
For directories, the content is returned as a JSON array with MIME type `application/json`.
## Error Handling
The resource handler may throw the following errors:
- `AzureDevOpsResourceNotFoundError`: If the specified resource cannot be found (project, repository, path, or version)
- `AzureDevOpsAuthenticationError`: If authentication fails
- `AzureDevOpsValidationError`: If the URI format is invalid
- Other errors: For unexpected issues
## Related Tools
While resource URIs provide direct access to repository content, you can also use the following tools for more advanced operations:
- `get_file_content`: Get content of a file or directory with more options and metadata
- `get_repository`: Get details about a specific repository
- `get_repository_details`: Get comprehensive repository information including statistics and refs
- `list_repositories`: List all repositories in a project
- `search_code`: Search for code in repositories
```
--------------------------------------------------------------------------------
/src/features/work-items/list-work-items/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { listWorkItems } from './feature';
import { createWorkItem } from '../create-work-item/feature';
import {
getTestConnection,
shouldSkipIntegrationTest,
} from '@/shared/test/test-helpers';
import { CreateWorkItemOptions, ListWorkItemsOptions } from '../types';
describe('listWorkItems integration', () => {
let connection: WebApi | null = null;
const createdWorkItemIds: number[] = [];
let projectName: string;
beforeAll(async () => {
// Get a real connection using environment variables
connection = await getTestConnection();
projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
// Skip setup if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
return;
}
// Create a few work items to ensure we have data to list
const testPrefix = `List Test ${new Date().toISOString().slice(0, 16)}`;
for (let i = 0; i < 3; i++) {
const options: CreateWorkItemOptions = {
title: `${testPrefix} - Item ${i + 1}`,
description: `Test item ${i + 1} for list-work-items integration tests`,
priority: 2,
additionalFields: {
'System.Tags': 'ListTest,Integration',
},
};
try {
const workItem = await createWorkItem(
connection,
projectName,
'Task',
options,
);
if (workItem && workItem.id !== undefined) {
createdWorkItemIds.push(workItem.id);
}
} catch (error) {
console.error(`Failed to create test work item ${i + 1}:`, error);
}
}
});
test('should list work items from a project', async () => {
// Skip if no connection is available
if (shouldSkipIntegrationTest() || !connection) {
return;
}
const options: ListWorkItemsOptions = {
projectId: projectName,
};
// Act - make an actual API call to Azure DevOps
const result = await listWorkItems(connection, options);
// Assert on the actual response
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Should have at least some work items (including our created ones)
expect(result.length).toBeGreaterThan(0);
// Check basic structure of returned work items
const firstItem = result[0];
expect(firstItem.id).toBeDefined();
expect(firstItem.fields).toBeDefined();
if (firstItem.fields) {
expect(firstItem.fields['System.Title']).toBeDefined();
}
});
test('should apply pagination options', async () => {
// Skip if no connection is available
if (shouldSkipIntegrationTest() || !connection) {
return;
}
// First get all items to know the total count
const allOptions: ListWorkItemsOptions = {
projectId: projectName,
};
const allItems = await listWorkItems(connection, allOptions);
// Then get with pagination
const paginationOptions: ListWorkItemsOptions = {
projectId: projectName,
top: 2, // Only get first 2 items
};
const paginatedResult = await listWorkItems(connection, paginationOptions);
// Assert on pagination
expect(paginatedResult).toBeDefined();
expect(paginatedResult.length).toBeLessThanOrEqual(2);
// If we have more than 2 total items, pagination should have limited results
if (allItems.length > 2) {
expect(paginatedResult.length).toBe(2);
expect(paginatedResult.length).toBeLessThan(allItems.length);
}
});
test('should list work items with custom WIQL query', async () => {
// Skip if no connection is available or if we didn't create any test items
if (
shouldSkipIntegrationTest() ||
!connection ||
createdWorkItemIds.length === 0
) {
return;
}
// Create a more specific WIQL query that includes the IDs of our created work items
const workItemIdList = createdWorkItemIds.join(',');
const wiql = `SELECT [System.Id], [System.Title] FROM WorkItems WHERE [System.TeamProject] = '${projectName}' AND [System.Id] IN (${workItemIdList}) AND [System.Tags] CONTAINS 'ListTest' ORDER BY [System.Id]`;
const options: ListWorkItemsOptions = {
projectId: projectName,
wiql,
};
// Act - make an actual API call to Azure DevOps
const result = await listWorkItems(connection, options);
// Assert on the actual response
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// Should have found our test items with the ListTest tag
expect(result.length).toBeGreaterThan(0);
// At least one of our created items should be in the results
const foundCreatedItem = result.some((item) =>
createdWorkItemIds.includes(item.id || -1),
);
expect(foundCreatedItem).toBe(true);
});
});
```
--------------------------------------------------------------------------------
/src/features/users/get-me/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import axios, { AxiosError } from 'axios';
import { getMe } from './feature';
import {
AzureDevOpsError,
AzureDevOpsAuthenticationError,
} from '@/shared/errors';
// Mock axios
jest.mock('axios');
const mockAxios = axios as jest.Mocked<typeof axios>;
// Mock env variables
const originalEnv = process.env;
describe('getMe', () => {
let mockConnection: WebApi;
beforeEach(() => {
// Reset mocks
jest.resetAllMocks();
// Mock WebApi with a server URL
mockConnection = {
serverUrl: 'https://dev.azure.com/testorg',
} as WebApi;
// Mock environment variables for PAT authentication
process.env = {
...originalEnv,
AZURE_DEVOPS_AUTH_METHOD: 'pat',
AZURE_DEVOPS_PAT: 'test-pat',
};
});
afterEach(() => {
// Restore original env
process.env = originalEnv;
});
it('should return user profile with id, displayName, and email', async () => {
// Arrange
const mockProfile = {
id: 'user-id-123',
displayName: 'Test User',
emailAddress: '[email protected]',
coreRevision: 1647,
timeStamp: '2023-01-01T00:00:00.000Z',
revision: 1647,
};
// Mock axios get to return profile data
mockAxios.get.mockResolvedValue({ data: mockProfile });
// Act
const result = await getMe(mockConnection);
// Assert
expect(mockAxios.get).toHaveBeenCalledWith(
'https://vssps.dev.azure.com/testorg/_apis/profile/profiles/me?api-version=7.1',
expect.any(Object),
);
expect(result).toEqual({
id: 'user-id-123',
displayName: 'Test User',
email: '[email protected]',
});
});
it('should handle missing email', async () => {
// Arrange
const mockProfile = {
id: 'user-id-123',
displayName: 'Test User',
// No emailAddress
coreRevision: 1647,
timeStamp: '2023-01-01T00:00:00.000Z',
revision: 1647,
};
// Mock axios get to return profile data
mockAxios.get.mockResolvedValue({ data: mockProfile });
// Act
const result = await getMe(mockConnection);
// Assert
expect(result.email).toBe('');
});
it('should handle missing display name', async () => {
// Arrange
const mockProfile = {
id: 'user-id-123',
// No displayName
emailAddress: '[email protected]',
coreRevision: 1647,
timeStamp: '2023-01-01T00:00:00.000Z',
revision: 1647,
};
// Mock axios get to return profile data
mockAxios.get.mockResolvedValue({ data: mockProfile });
// Act
const result = await getMe(mockConnection);
// Assert
expect(result.displayName).toBe('');
});
it('should handle authentication errors', async () => {
// Arrange
const axiosError = {
isAxiosError: true,
response: {
status: 401,
data: { message: 'Unauthorized' },
},
message: 'Request failed with status code 401',
} as AxiosError;
// Mock axios get to throw error
mockAxios.get.mockRejectedValue(axiosError);
// Mock axios.isAxiosError function
jest.spyOn(axios, 'isAxiosError').mockImplementation(() => true);
// Act & Assert
await expect(getMe(mockConnection)).rejects.toThrow(
AzureDevOpsAuthenticationError,
);
await expect(getMe(mockConnection)).rejects.toThrow(
/Authentication failed/,
);
});
it('should wrap general errors in AzureDevOpsError', async () => {
// Arrange
const testError = new Error('Test API error');
mockAxios.get.mockRejectedValue(testError);
// Mock axios.isAxiosError function
jest.spyOn(axios, 'isAxiosError').mockImplementation(() => false);
// Act & Assert
await expect(getMe(mockConnection)).rejects.toThrow(AzureDevOpsError);
await expect(getMe(mockConnection)).rejects.toThrow(
'Failed to get user information: Test API error',
);
});
// Test the legacy URL format of project.visualstudio.com
it('should work with legacy visualstudio.com URL format', async () => {
mockConnection = {
serverUrl: 'https://legacy_test_org.visualstudio.com',
} as WebApi;
const mockProfile = {
id: 'user-id-123',
displayName: 'Test User',
emailAddress: '[email protected]',
coreRevision: 1647,
timeStamp: '2023-01-01T00:00:00.000Z',
revision: 1647,
};
mockAxios.get.mockResolvedValue({ data: mockProfile });
const result = await getMe(mockConnection);
// Verify that the organization name was correctly extracted from the legacy URL
expect(mockAxios.get).toHaveBeenCalledWith(
'https://vssps.dev.azure.com/legacy_test_org/_apis/profile/profiles/me?api-version=7.1',
expect.any(Object),
);
expect(result).toEqual({
id: 'user-id-123',
displayName: 'Test User',
email: '[email protected]',
});
});
});
```
--------------------------------------------------------------------------------
/src/features/work-items/schemas.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
import { defaultProject, defaultOrg } from '../../utils/environment';
/**
* Schema for getting a work item
*/
export const GetWorkItemSchema = z.object({
workItemId: z.number().describe('The ID of the work item'),
expand: z
.enum(['none', 'relations', 'fields', 'links', 'all'])
.optional()
.describe(
'The level of detail to include in the response. Defaults to "all" if not specified.',
),
});
/**
* Schema for listing work items
*/
export const ListWorkItemsSchema = z.object({
projectId: z
.string()
.optional()
.describe(`The ID or name of the project (Default: ${defaultProject})`),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
teamId: z.string().optional().describe('The ID of the team'),
queryId: z.string().optional().describe('ID of a saved work item query'),
wiql: z.string().optional().describe('Work Item Query Language (WIQL) query'),
top: z.number().optional().describe('Maximum number of work items to return'),
skip: z.number().optional().describe('Number of work items to skip'),
});
/**
* Schema for creating a work item
*/
export const CreateWorkItemSchema = z.object({
projectId: z
.string()
.optional()
.describe(`The ID or name of the project (Default: ${defaultProject})`),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
workItemType: z
.string()
.describe(
'The type of work item to create (e.g., "Task", "Bug", "User Story")',
),
title: z.string().describe('The title of the work item'),
description: z
.string()
.optional()
.describe(
'Work item description in HTML format. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.',
),
assignedTo: z
.string()
.optional()
.describe('The email or name of the user to assign the work item to'),
areaPath: z.string().optional().describe('The area path for the work item'),
iterationPath: z
.string()
.optional()
.describe('The iteration path for the work item'),
priority: z.number().optional().describe('The priority of the work item'),
parentId: z
.number()
.optional()
.describe('The ID of the parent work item to create a relationship with'),
additionalFields: z
.record(z.string(), z.any())
.optional()
.describe(
'Additional fields to set on the work item. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.',
),
});
/**
* Schema for updating a work item
*/
export const UpdateWorkItemSchema = z.object({
workItemId: z.number().describe('The ID of the work item to update'),
title: z.string().optional().describe('The updated title of the work item'),
description: z
.string()
.optional()
.describe(
'Work item description in HTML format. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.',
),
assignedTo: z
.string()
.optional()
.describe('The email or name of the user to assign the work item to'),
areaPath: z
.string()
.optional()
.describe('The updated area path for the work item'),
iterationPath: z
.string()
.optional()
.describe('The updated iteration path for the work item'),
priority: z
.number()
.optional()
.describe('The updated priority of the work item'),
state: z.string().optional().describe('The updated state of the work item'),
additionalFields: z
.record(z.string(), z.any())
.optional()
.describe(
'Additional fields to update on the work item. Multi-line text fields (i.e., System.History, AcceptanceCriteria, etc.) must use HTML format. Do not use CDATA tags.',
),
});
/**
* Schema for managing work item links
*/
export const ManageWorkItemLinkSchema = z.object({
sourceWorkItemId: z.number().describe('The ID of the source work item'),
targetWorkItemId: z.number().describe('The ID of the target work item'),
projectId: z
.string()
.optional()
.describe(`The ID or name of the project (Default: ${defaultProject})`),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
operation: z
.enum(['add', 'remove', 'update'])
.describe('The operation to perform on the link'),
relationType: z
.string()
.describe(
'The reference name of the relation type (e.g., "System.LinkTypes.Hierarchy-Forward")',
),
newRelationType: z
.string()
.optional()
.describe('The new relation type to use when updating a link'),
comment: z
.string()
.optional()
.describe('Optional comment explaining the link'),
});
```
--------------------------------------------------------------------------------
/src/features/repositories/get-all-repositories-tree/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { getConnection } from '../../../server';
import { shouldSkipIntegrationTest } from '../../../shared/test/test-helpers';
import { getAllRepositoriesTree } from './feature';
import { AzureDevOpsConfig } from '../../../shared/types';
import { WebApi } from 'azure-devops-node-api';
import { AuthenticationMethod } from '../../../shared/auth';
// Skip tests if no PAT is available
const hasPat = process.env.AZURE_DEVOPS_PAT && process.env.AZURE_DEVOPS_ORG_URL;
const describeOrSkip = hasPat ? describe : describe.skip;
describeOrSkip('getAllRepositoriesTree (Integration)', () => {
let connection: WebApi;
let config: AzureDevOpsConfig;
let projectId: string;
let orgId: string;
beforeAll(async () => {
if (shouldSkipIntegrationTest()) {
return;
}
// Configuration values
config = {
organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '',
authMethod: AuthenticationMethod.PersonalAccessToken,
personalAccessToken: process.env.AZURE_DEVOPS_PAT || '',
defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '',
};
// Use test project - should be defined in .env file
projectId =
process.env.AZURE_DEVOPS_TEST_PROJECT_ID ||
process.env.AZURE_DEVOPS_DEFAULT_PROJECT ||
'';
// Extract organization ID from URL
const url = new URL(config.organizationUrl);
const pathParts = url.pathname.split('/').filter(Boolean);
orgId = pathParts[0] || '';
// Get Azure DevOps connection
connection = await getConnection(config);
// Skip tests if no project ID is set
if (!projectId) {
console.warn('Skipping integration tests: No test project ID set');
}
}, 30000);
// Skip all tests if integration tests are disabled
beforeEach(() => {
if (shouldSkipIntegrationTest()) {
jest.resetAllMocks();
return;
}
});
it('should retrieve tree for all repositories with maximum depth (default)', async () => {
// Skip test if no project ID or if integration tests are disabled
if (shouldSkipIntegrationTest() || !projectId) {
return;
}
const result = await getAllRepositoriesTree(connection, {
organizationId: orgId,
projectId: projectId,
// depth defaults to 0 (unlimited)
});
expect(result).toBeDefined();
expect(result.repositories).toBeDefined();
expect(Array.isArray(result.repositories)).toBe(true);
expect(result.repositories.length).toBeGreaterThan(0);
// Check that at least one repository has a tree
const repoWithTree = result.repositories.find((r) => r.tree.length > 0);
expect(repoWithTree).toBeDefined();
if (repoWithTree) {
// Verify that deep nesting is included (finding items with level > 2)
// Note: This might not always be true depending on repos, but there should be at least some nested items
const deepItems = repoWithTree.tree.filter((item) => item.level > 2);
expect(deepItems.length).toBeGreaterThan(0);
// Verify stats are correct
expect(repoWithTree.stats.directories).toBeGreaterThanOrEqual(0);
expect(repoWithTree.stats.files).toBeGreaterThan(0);
const dirCount = repoWithTree.tree.filter((item) => item.isFolder).length;
const fileCount = repoWithTree.tree.filter(
(item) => !item.isFolder,
).length;
expect(repoWithTree.stats.directories).toBe(dirCount);
expect(repoWithTree.stats.files).toBe(fileCount);
}
}, 60000); // Longer timeout because max depth can take time
it('should retrieve tree for all repositories with limited depth (depth=1)', async () => {
// Skip test if no project ID or if integration tests are disabled
if (shouldSkipIntegrationTest() || !projectId) {
return;
}
const result = await getAllRepositoriesTree(connection, {
organizationId: orgId,
projectId: projectId,
depth: 1, // Only 1 level deep
});
expect(result).toBeDefined();
expect(result.repositories).toBeDefined();
expect(Array.isArray(result.repositories)).toBe(true);
expect(result.repositories.length).toBeGreaterThan(0);
// Check that at least one repository has a tree
const repoWithTree = result.repositories.find((r) => r.tree.length > 0);
expect(repoWithTree).toBeDefined();
if (repoWithTree) {
// Verify that only shallow nesting is included (all items should have level = 1)
const allItemsLevel1 = repoWithTree.tree.every(
(item) => item.level === 1,
);
expect(allItemsLevel1).toBe(true);
// Verify stats are correct
expect(repoWithTree.stats.directories).toBeGreaterThanOrEqual(0);
expect(repoWithTree.stats.files).toBeGreaterThanOrEqual(0);
const dirCount = repoWithTree.tree.filter((item) => item.isFolder).length;
const fileCount = repoWithTree.tree.filter(
(item) => !item.isFolder,
).length;
expect(repoWithTree.stats.directories).toBe(dirCount);
expect(repoWithTree.stats.files).toBe(fileCount);
}
}, 30000);
});
```
--------------------------------------------------------------------------------
/src/shared/enums/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import {
CommentThreadStatus,
CommentType,
GitVersionType,
PullRequestStatus,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import {
commentThreadStatusMapper,
commentTypeMapper,
pullRequestStatusMapper,
gitVersionTypeMapper,
} from './index';
describe('Enum Mappers', () => {
describe('commentThreadStatusMapper', () => {
it('should map string values to enum values correctly', () => {
expect(commentThreadStatusMapper.toEnum('active')).toBe(
CommentThreadStatus.Active,
);
expect(commentThreadStatusMapper.toEnum('fixed')).toBe(
CommentThreadStatus.Fixed,
);
expect(commentThreadStatusMapper.toEnum('wontfix')).toBe(
CommentThreadStatus.WontFix,
);
expect(commentThreadStatusMapper.toEnum('closed')).toBe(
CommentThreadStatus.Closed,
);
expect(commentThreadStatusMapper.toEnum('bydesign')).toBe(
CommentThreadStatus.ByDesign,
);
expect(commentThreadStatusMapper.toEnum('pending')).toBe(
CommentThreadStatus.Pending,
);
expect(commentThreadStatusMapper.toEnum('unknown')).toBe(
CommentThreadStatus.Unknown,
);
});
it('should map enum values to string values correctly', () => {
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.Active),
).toBe('active');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.Fixed),
).toBe('fixed');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.WontFix),
).toBe('wontfix');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.Closed),
).toBe('closed');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.ByDesign),
).toBe('bydesign');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.Pending),
).toBe('pending');
expect(
commentThreadStatusMapper.toString(CommentThreadStatus.Unknown),
).toBe('unknown');
});
it('should handle case insensitive string input', () => {
expect(commentThreadStatusMapper.toEnum('ACTIVE')).toBe(
CommentThreadStatus.Active,
);
expect(commentThreadStatusMapper.toEnum('Active')).toBe(
CommentThreadStatus.Active,
);
});
it('should return undefined for invalid string values', () => {
expect(commentThreadStatusMapper.toEnum('invalid')).toBeUndefined();
});
it('should return default value for invalid enum values', () => {
expect(commentThreadStatusMapper.toString(999)).toBe('unknown');
});
});
describe('commentTypeMapper', () => {
it('should map string values to enum values correctly', () => {
expect(commentTypeMapper.toEnum('text')).toBe(CommentType.Text);
expect(commentTypeMapper.toEnum('codechange')).toBe(
CommentType.CodeChange,
);
expect(commentTypeMapper.toEnum('system')).toBe(CommentType.System);
expect(commentTypeMapper.toEnum('unknown')).toBe(CommentType.Unknown);
});
it('should map enum values to string values correctly', () => {
expect(commentTypeMapper.toString(CommentType.Text)).toBe('text');
expect(commentTypeMapper.toString(CommentType.CodeChange)).toBe(
'codechange',
);
expect(commentTypeMapper.toString(CommentType.System)).toBe('system');
expect(commentTypeMapper.toString(CommentType.Unknown)).toBe('unknown');
});
});
describe('pullRequestStatusMapper', () => {
it('should map string values to enum values correctly', () => {
expect(pullRequestStatusMapper.toEnum('active')).toBe(
PullRequestStatus.Active,
);
expect(pullRequestStatusMapper.toEnum('abandoned')).toBe(
PullRequestStatus.Abandoned,
);
expect(pullRequestStatusMapper.toEnum('completed')).toBe(
PullRequestStatus.Completed,
);
});
it('should map enum values to string values correctly', () => {
expect(pullRequestStatusMapper.toString(PullRequestStatus.Active)).toBe(
'active',
);
expect(
pullRequestStatusMapper.toString(PullRequestStatus.Abandoned),
).toBe('abandoned');
expect(
pullRequestStatusMapper.toString(PullRequestStatus.Completed),
).toBe('completed');
});
});
describe('gitVersionTypeMapper', () => {
it('should map string values to enum values correctly', () => {
expect(gitVersionTypeMapper.toEnum('branch')).toBe(GitVersionType.Branch);
expect(gitVersionTypeMapper.toEnum('commit')).toBe(GitVersionType.Commit);
expect(gitVersionTypeMapper.toEnum('tag')).toBe(GitVersionType.Tag);
});
it('should map enum values to string values correctly', () => {
expect(gitVersionTypeMapper.toString(GitVersionType.Branch)).toBe(
'branch',
);
expect(gitVersionTypeMapper.toString(GitVersionType.Commit)).toBe(
'commit',
);
expect(gitVersionTypeMapper.toString(GitVersionType.Tag)).toBe('tag');
});
});
});
```
--------------------------------------------------------------------------------
/src/features/search/schemas.ts:
--------------------------------------------------------------------------------
```typescript
import { z } from 'zod';
import { defaultOrg, defaultProject } from '../../utils/environment';
/**
* Schema for searching code in Azure DevOps repositories
*/
export const SearchCodeSchema = z
.object({
searchText: z.string().describe('The text to search for'),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
projectId: z
.string()
.optional()
.describe(
`The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`,
),
filters: z
.object({
Repository: z
.array(z.string())
.optional()
.describe('Filter by repository names'),
Path: z.array(z.string()).optional().describe('Filter by file paths'),
Branch: z
.array(z.string())
.optional()
.describe('Filter by branch names'),
CodeElement: z
.array(z.string())
.optional()
.describe('Filter by code element types (function, class, etc.)'),
})
.optional()
.describe('Optional filters to narrow search results'),
top: z
.number()
.int()
.min(1)
.max(1000)
.default(100)
.describe('Number of results to return (default: 100, max: 1000)'),
skip: z
.number()
.int()
.min(0)
.default(0)
.describe('Number of results to skip for pagination (default: 0)'),
includeSnippet: z
.boolean()
.default(true)
.describe('Whether to include code snippets in results (default: true)'),
includeContent: z
.boolean()
.default(true)
.describe(
'Whether to include full file content in results (default: true)',
),
})
.transform((data) => {
return {
...data,
organizationId: data.organizationId ?? defaultOrg,
projectId: data.projectId ?? defaultProject,
};
});
/**
* Schema for searching wiki pages in Azure DevOps projects
*/
export const SearchWikiSchema = z.object({
searchText: z.string().describe('The text to search for in wikis'),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
projectId: z
.string()
.optional()
.describe(
`The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`,
),
filters: z
.object({
Project: z
.array(z.string())
.optional()
.describe('Filter by project names'),
})
.optional()
.describe('Optional filters to narrow search results'),
top: z
.number()
.int()
.min(1)
.max(1000)
.default(100)
.describe('Number of results to return (default: 100, max: 1000)'),
skip: z
.number()
.int()
.min(0)
.default(0)
.describe('Number of results to skip for pagination (default: 0)'),
includeFacets: z
.boolean()
.default(true)
.describe('Whether to include faceting in results (default: true)'),
});
/**
* Schema for searching work items in Azure DevOps projects
*/
export const SearchWorkItemsSchema = z.object({
searchText: z.string().describe('The text to search for in work items'),
organizationId: z
.string()
.optional()
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
projectId: z
.string()
.optional()
.describe(
`The ID or name of the project to search in (Default: ${defaultProject}). If not provided, the default project will be used.`,
),
filters: z
.object({
'System.TeamProject': z
.array(z.string())
.optional()
.describe('Filter by project names'),
'System.WorkItemType': z
.array(z.string())
.optional()
.describe('Filter by work item types (Bug, Task, User Story, etc.)'),
'System.State': z
.array(z.string())
.optional()
.describe('Filter by work item states (New, Active, Closed, etc.)'),
'System.AssignedTo': z
.array(z.string())
.optional()
.describe('Filter by assigned users'),
'System.AreaPath': z
.array(z.string())
.optional()
.describe('Filter by area paths'),
})
.optional()
.describe('Optional filters to narrow search results'),
top: z
.number()
.int()
.min(1)
.max(1000)
.default(100)
.describe('Number of results to return (default: 100, max: 1000)'),
skip: z
.number()
.int()
.min(0)
.default(0)
.describe('Number of results to skip for pagination (default: 0)'),
includeFacets: z
.boolean()
.default(true)
.describe('Whether to include faceting in results (default: true)'),
orderBy: z
.array(
z.object({
field: z.string().describe('Field to sort by'),
sortOrder: z.enum(['ASC', 'DESC']).describe('Sort order (ASC/DESC)'),
}),
)
.optional()
.describe('Options for sorting search results'),
});
```
--------------------------------------------------------------------------------
/src/features/search/index.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { isSearchRequest, handleSearchRequest } from './index';
import { searchCode } from './search-code';
import { searchWiki } from './search-wiki';
import { searchWorkItems } from './search-work-items';
// Mock the imported modules
jest.mock('./search-code', () => ({
searchCode: jest.fn(),
}));
jest.mock('./search-wiki', () => ({
searchWiki: jest.fn(),
}));
jest.mock('./search-work-items', () => ({
searchWorkItems: jest.fn(),
}));
describe('Search Request Handlers', () => {
const mockConnection = {} as WebApi;
describe('isSearchRequest', () => {
it('should return true for search requests', () => {
const validTools = ['search_code', 'search_wiki', 'search_work_items'];
validTools.forEach((tool) => {
const request = {
params: { name: tool, arguments: {} },
method: 'tools/call',
} as CallToolRequest;
expect(isSearchRequest(request)).toBe(true);
});
});
it('should return false for non-search requests', () => {
const request = {
params: { name: 'list_projects', arguments: {} },
method: 'tools/call',
} as CallToolRequest;
expect(isSearchRequest(request)).toBe(false);
});
});
describe('handleSearchRequest', () => {
it('should handle search_code request', async () => {
const mockSearchResults = {
count: 2,
results: [
{ fileName: 'file1.ts', path: '/path/to/file1.ts' },
{ fileName: 'file2.ts', path: '/path/to/file2.ts' },
],
};
(searchCode as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_code',
arguments: {
searchText: 'function',
projectId: 'project1',
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleSearchRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockSearchResults,
);
expect(searchCode).toHaveBeenCalledWith(
mockConnection,
expect.objectContaining({
searchText: 'function',
projectId: 'project1',
}),
);
});
it('should handle search_wiki request', async () => {
const mockSearchResults = {
count: 1,
results: [{ title: 'Wiki Page', path: '/path/to/page' }],
};
(searchWiki as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_wiki',
arguments: {
searchText: 'documentation',
projectId: 'project1',
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleSearchRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockSearchResults,
);
expect(searchWiki).toHaveBeenCalledWith(
mockConnection,
expect.objectContaining({
searchText: 'documentation',
projectId: 'project1',
}),
);
});
it('should handle search_work_items request', async () => {
const mockSearchResults = {
count: 2,
results: [
{ id: 1, title: 'Bug 1' },
{ id: 2, title: 'Feature 2' },
],
};
(searchWorkItems as jest.Mock).mockResolvedValue(mockSearchResults);
const request = {
params: {
name: 'search_work_items',
arguments: {
searchText: 'bug',
projectId: 'project1',
},
},
method: 'tools/call',
} as CallToolRequest;
const response = await handleSearchRequest(mockConnection, request);
expect(response.content).toHaveLength(1);
expect(JSON.parse(response.content[0].text as string)).toEqual(
mockSearchResults,
);
expect(searchWorkItems).toHaveBeenCalledWith(
mockConnection,
expect.objectContaining({
searchText: 'bug',
projectId: 'project1',
}),
);
});
it('should throw error for unknown tool', async () => {
const request = {
params: {
name: 'unknown_tool',
arguments: {},
},
method: 'tools/call',
} as CallToolRequest;
await expect(
handleSearchRequest(mockConnection, request),
).rejects.toThrow('Unknown search tool');
});
it('should propagate errors from search functions', async () => {
const mockError = new Error('Test error');
(searchCode as jest.Mock).mockRejectedValue(mockError);
const request = {
params: {
name: 'search_code',
arguments: {
searchText: 'function',
},
},
method: 'tools/call',
} as CallToolRequest;
await expect(
handleSearchRequest(mockConnection, request),
).rejects.toThrow(mockError);
});
});
});
```
--------------------------------------------------------------------------------
/src/features/pipelines/index.ts:
--------------------------------------------------------------------------------
```typescript
// Re-export types
export * from './types';
// Re-export features
export * from './list-pipelines';
export * from './get-pipeline';
export * from './list-pipeline-runs';
export * from './get-pipeline-run';
export * from './download-pipeline-artifact';
export * from './pipeline-timeline';
export * from './get-pipeline-log';
export * from './trigger-pipeline';
// Export tool definitions
export * from './tool-definitions';
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { WebApi } from 'azure-devops-node-api';
import {
RequestIdentifier,
RequestHandler,
} from '../../shared/types/request-handler';
import { ListPipelinesSchema } from './list-pipelines';
import { GetPipelineSchema } from './get-pipeline';
import { ListPipelineRunsSchema } from './list-pipeline-runs';
import { GetPipelineRunSchema } from './get-pipeline-run';
import { DownloadPipelineArtifactSchema } from './download-pipeline-artifact';
import { GetPipelineTimelineSchema } from './pipeline-timeline';
import { GetPipelineLogSchema } from './get-pipeline-log';
import { TriggerPipelineSchema } from './trigger-pipeline';
import { listPipelines } from './list-pipelines';
import { getPipeline } from './get-pipeline';
import { listPipelineRuns } from './list-pipeline-runs';
import { getPipelineRun } from './get-pipeline-run';
import { downloadPipelineArtifact } from './download-pipeline-artifact';
import { getPipelineTimeline } from './pipeline-timeline';
import { getPipelineLog } from './get-pipeline-log';
import { triggerPipeline } from './trigger-pipeline';
import { defaultProject } from '../../utils/environment';
/**
* Checks if the request is for the pipelines feature
*/
export const isPipelinesRequest: RequestIdentifier = (
request: CallToolRequest,
): boolean => {
const toolName = request.params.name;
return [
'list_pipelines',
'get_pipeline',
'list_pipeline_runs',
'get_pipeline_run',
'download_pipeline_artifact',
'pipeline_timeline',
'get_pipeline_log',
'trigger_pipeline',
].includes(toolName);
};
/**
* Handles pipelines feature requests
*/
export const handlePipelinesRequest: RequestHandler = async (
connection: WebApi,
request: CallToolRequest,
): Promise<{ content: Array<{ type: string; text: string }> }> => {
switch (request.params.name) {
case 'list_pipelines': {
const args = ListPipelinesSchema.parse(request.params.arguments);
const result = await listPipelines(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pipeline': {
const args = GetPipelineSchema.parse(request.params.arguments);
const result = await getPipeline(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'list_pipeline_runs': {
const args = ListPipelineRunsSchema.parse(request.params.arguments);
const result = await listPipelineRuns(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pipeline_run': {
const args = GetPipelineRunSchema.parse(request.params.arguments);
const result = await getPipelineRun(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'download_pipeline_artifact': {
const args = DownloadPipelineArtifactSchema.parse(
request.params.arguments,
);
const result = await downloadPipelineArtifact(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'pipeline_timeline': {
const args = GetPipelineTimelineSchema.parse(request.params.arguments);
const result = await getPipelineTimeline(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pipeline_log': {
const args = GetPipelineLogSchema.parse(request.params.arguments);
const result = await getPipelineLog(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
const text =
typeof result === 'string' ? result : JSON.stringify(result, null, 2);
return {
content: [{ type: 'text', text }],
};
}
case 'trigger_pipeline': {
const args = TriggerPipelineSchema.parse(request.params.arguments);
const result = await triggerPipeline(connection, {
...args,
projectId: args.projectId ?? defaultProject,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
default:
throw new Error(`Unknown pipelines tool: ${request.params.name}`);
}
};
```
--------------------------------------------------------------------------------
/src/features/repositories/create-commit/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { Readable } from 'stream';
import { createCommit } from './feature';
import { AzureDevOpsError } from '../../../shared/errors';
import { createTwoFilesPatch } from 'diff';
import { VersionControlChangeType } from 'azure-devops-node-api/interfaces/GitInterfaces';
describe('createCommit unit', () => {
test('should create push with provided changes', async () => {
const createPush = jest.fn().mockResolvedValue({});
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getBranch: jest
.fn()
.mockResolvedValue({ commit: { commitId: 'base' } }),
getItemContent: jest
.fn()
.mockResolvedValue(Readable.from(['console.log("hello");\n'])),
createPush,
}),
};
await createCommit(mockConnection, {
projectId: 'p',
repositoryId: 'r',
branchName: 'main',
commitMessage: 'msg',
changes: [
{
path: '/file.ts',
patch: createTwoFilesPatch(
'/file.ts',
'/file.ts',
'console.log("hello");\n',
'console.log("world");\n',
),
},
{
path: '/new.txt',
patch: createTwoFilesPatch('/dev/null', '/new.txt', '', 'hi\n'),
},
],
});
expect(createPush).toHaveBeenCalled();
const payload = createPush.mock.calls[0][0];
expect(payload.commits[0].changes).toHaveLength(2);
});
test('should throw when snippet not found', async () => {
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getBranch: jest
.fn()
.mockResolvedValue({ commit: { commitId: 'base' } }),
getItemContent: jest
.fn()
.mockResolvedValue(Readable.from(['nothing here'])),
}),
};
await expect(
createCommit(mockConnection, {
projectId: 'p',
repositoryId: 'r',
branchName: 'main',
commitMessage: 'msg',
changes: [
{
path: '/file.ts',
patch: createTwoFilesPatch(
'/file.ts',
'/file.ts',
'console.log("hello");\n',
'console.log("world");\n',
),
},
],
}),
).rejects.toThrow(AzureDevOpsError);
});
test('should create delete change when patch removes a file', async () => {
const createPush = jest.fn().mockResolvedValue({});
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getBranch: jest
.fn()
.mockResolvedValue({ commit: { commitId: 'base' } }),
getItemContent: jest
.fn()
.mockResolvedValue(Readable.from(['goodbye\n'])),
createPush,
}),
};
await createCommit(mockConnection, {
projectId: 'p',
repositoryId: 'r',
branchName: 'main',
commitMessage: 'msg',
changes: [
{
patch: createTwoFilesPatch('/old.txt', '/dev/null', 'goodbye\n', ''),
},
],
});
expect(createPush).toHaveBeenCalled();
const payload = createPush.mock.calls[0][0];
const change = payload.commits[0].changes[0];
expect(change.changeType).toBe(VersionControlChangeType.Delete);
expect(change.item).toEqual({ path: '/old.txt' });
expect(change.newContent).toBeUndefined();
});
test('should handle search/replace format', async () => {
const createPush = jest.fn().mockResolvedValue({});
const fileContent = 'const x = 1;\nconsole.log("hello");\n';
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getBranch: jest
.fn()
.mockResolvedValue({ commit: { commitId: 'base' } }),
getItemContent: jest
.fn()
.mockImplementation(() => Readable.from([fileContent])),
createPush,
}),
};
await createCommit(mockConnection, {
projectId: 'p',
repositoryId: 'r',
branchName: 'main',
commitMessage: 'msg',
changes: [
{
path: '/file.ts',
search: 'console.log("hello");',
replace: 'console.log("world");',
},
],
});
expect(createPush).toHaveBeenCalled();
const payload = createPush.mock.calls[0][0];
expect(payload.commits[0].changes).toHaveLength(1);
const change = payload.commits[0].changes[0];
expect(change.changeType).toBe(VersionControlChangeType.Edit);
expect(change.newContent?.content).toContain('console.log("world");');
});
test('should throw when search string not found', async () => {
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getBranch: jest
.fn()
.mockResolvedValue({ commit: { commitId: 'base' } }),
getItemContent: jest
.fn()
.mockResolvedValue(Readable.from(['const x = 1;\n'])),
}),
};
await expect(
createCommit(mockConnection, {
projectId: 'p',
repositoryId: 'r',
branchName: 'main',
commitMessage: 'msg',
changes: [
{
path: '/file.ts',
search: 'NOT_FOUND',
replace: 'something',
},
],
}),
).rejects.toThrow(AzureDevOpsError);
});
});
```
--------------------------------------------------------------------------------
/src/shared/auth/auth-factory.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api';
import { BearerCredentialHandler } from 'azure-devops-node-api/handlers/bearertoken';
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
import { AzureDevOpsAuthenticationError } from '../errors';
/**
* Authentication methods supported by the Azure DevOps client
*/
export enum AuthenticationMethod {
/**
* Personal Access Token authentication
*/
PersonalAccessToken = 'pat',
/**
* Azure Identity authentication (DefaultAzureCredential)
*/
AzureIdentity = 'azure-identity',
/**
* Azure CLI authentication (AzureCliCredential)
*/
AzureCli = 'azure-cli',
}
/**
* Authentication configuration for Azure DevOps
*/
export interface AuthConfig {
/**
* Authentication method to use
*/
method: AuthenticationMethod;
/**
* Organization URL (e.g., https://dev.azure.com/myorg)
*/
organizationUrl: string;
/**
* Personal Access Token for Azure DevOps (required for PAT authentication)
*/
personalAccessToken?: string;
}
/**
* Azure DevOps resource ID for token acquisition
*/
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
/**
* Creates an authenticated client for Azure DevOps API based on the specified authentication method
*
* @param config Authentication configuration
* @returns Authenticated WebApi client
* @throws {AzureDevOpsAuthenticationError} If authentication fails
*/
export async function createAuthClient(config: AuthConfig): Promise<WebApi> {
if (!config.organizationUrl) {
throw new AzureDevOpsAuthenticationError('Organization URL is required');
}
try {
let client: WebApi;
switch (config.method) {
case AuthenticationMethod.PersonalAccessToken:
client = await createPatClient(config);
break;
case AuthenticationMethod.AzureIdentity:
client = await createAzureIdentityClient(config);
break;
case AuthenticationMethod.AzureCli:
client = await createAzureCliClient(config);
break;
default:
throw new AzureDevOpsAuthenticationError(
`Unsupported authentication method: ${config.method}`,
);
}
// Test the connection
const locationsApi = await client.getLocationsApi();
await locationsApi.getResourceAreas();
return client;
} catch (error) {
if (error instanceof AzureDevOpsAuthenticationError) {
throw error;
}
throw new AzureDevOpsAuthenticationError(
`Failed to authenticate with Azure DevOps: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Creates a client using Personal Access Token authentication
*
* @param config Authentication configuration
* @returns Authenticated WebApi client
* @throws {AzureDevOpsAuthenticationError} If PAT is missing or authentication fails
*/
async function createPatClient(config: AuthConfig): Promise<WebApi> {
if (!config.personalAccessToken) {
throw new AzureDevOpsAuthenticationError(
'Personal Access Token is required',
);
}
// Create authentication handler using PAT
const authHandler = getPersonalAccessTokenHandler(config.personalAccessToken);
// Create API client with the auth handler
return new WebApi(config.organizationUrl, authHandler);
}
/**
* Creates a client using DefaultAzureCredential authentication
*
* @param config Authentication configuration
* @returns Authenticated WebApi client
* @throws {AzureDevOpsAuthenticationError} If token acquisition fails
*/
async function createAzureIdentityClient(config: AuthConfig): Promise<WebApi> {
try {
// Create DefaultAzureCredential
const credential = new DefaultAzureCredential();
// Get token for Azure DevOps
const token = await credential.getToken(
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
);
if (!token || !token.token) {
throw new Error('Failed to acquire token');
}
// Create bearer token handler
const authHandler = new BearerCredentialHandler(token.token);
// Create API client with the auth handler
return new WebApi(config.organizationUrl, authHandler);
} catch (error) {
throw new AzureDevOpsAuthenticationError(
`Failed to acquire Azure Identity token: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Creates a client using AzureCliCredential authentication
*
* @param config Authentication configuration
* @returns Authenticated WebApi client
* @throws {AzureDevOpsAuthenticationError} If token acquisition fails
*/
async function createAzureCliClient(config: AuthConfig): Promise<WebApi> {
try {
// Create AzureCliCredential
const credential = new AzureCliCredential();
// Get token for Azure DevOps
const token = await credential.getToken(
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
);
if (!token || !token.token) {
throw new Error('Failed to acquire token');
}
// Create bearer token handler
const authHandler = new BearerCredentialHandler(token.token);
// Create API client with the auth handler
return new WebApi(config.organizationUrl, authHandler);
} catch (error) {
throw new AzureDevOpsAuthenticationError(
`Failed to acquire Azure CLI token: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/pipelines/list-pipeline-runs/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
RunResult,
RunState,
} from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
import { listPipelineRuns } from './feature';
import {
AzureDevOpsAuthenticationError,
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
} from '../../../shared/errors';
import { Run } from '../types';
describe('listPipelineRuns unit', () => {
let mockConnection: WebApi;
let mockPipelinesApi: any;
let mockRestGet: jest.Mock;
const sampleRuns: Run[] = [
{
id: 101,
name: 'Run 101',
createdDate: new Date('2024-01-01T10:00:00Z'),
state: RunState.Completed,
result: RunResult.Succeeded,
url: 'https://dev.azure.com/org/project/_apis/pipelines/42/runs/101',
_links: {
web: { href: 'https://dev.azure.com/org/project/pipelines/run/101' },
},
},
];
beforeEach(() => {
jest.resetAllMocks();
mockRestGet = jest.fn();
mockPipelinesApi = {
rest: { get: mockRestGet },
createRequestOptions: jest
.fn()
.mockReturnValue({ acceptHeader: 'application/json' }),
formatResponse: jest.fn().mockImplementation((result: any) => {
if (!result) {
return [];
}
return result.value ?? [];
}),
};
mockConnection = {
serverUrl: 'https://dev.azure.com/testorg',
getPipelinesApi: jest.fn().mockResolvedValue(mockPipelinesApi),
} as unknown as WebApi;
});
it('returns runs with continuation token from headers', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { value: sampleRuns },
headers: { 'x-ms-continuationtoken': 'token-from-header' },
});
const result = await listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
});
expect(mockConnection.getPipelinesApi).toHaveBeenCalled();
expect(mockRestGet).toHaveBeenCalled();
expect(result.runs).toEqual(sampleRuns);
expect(result.continuationToken).toBe('token-from-header');
const [requestUrl] = mockRestGet.mock.calls[0];
const url = new URL(requestUrl);
expect(url.searchParams.get('api-version')).toBe('7.1');
expect(url.searchParams.get('$top')).toBe('50');
});
it('applies filters, pagination, and branch normalization', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { value: [], continuationToken: 'body-token' },
headers: {},
});
const createdFrom = '2024-01-01T00:00:00Z';
const createdTo = '2024-01-31T23:59:59Z';
await listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
top: 20,
continuationToken: 'next-token',
branch: 'main',
state: 'completed',
result: 'succeeded',
createdFrom,
createdTo,
orderBy: 'createdDate asc',
});
const [requestUrl] = mockRestGet.mock.calls[0];
const url = new URL(requestUrl);
expect(url.searchParams.get('$top')).toBe('20');
expect(url.searchParams.get('continuationToken')).toBe('next-token');
expect(url.searchParams.get('branch')).toBe('refs/heads/main');
expect(url.searchParams.get('state')).toBe('completed');
expect(url.searchParams.get('result')).toBe('succeeded');
expect(url.searchParams.get('createdDate/min')).toBe(createdFrom);
expect(url.searchParams.get('createdDate/max')).toBe(createdTo);
expect(url.searchParams.get('orderBy')).toBe('createdDate asc');
});
it('clamps top to 100 and preserves ref-prefixed branches', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { value: [] },
headers: {},
});
await listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
top: 150,
branch: 'refs/heads/develop',
});
const [requestUrl] = mockRestGet.mock.calls[0];
const url = new URL(requestUrl);
expect(url.searchParams.get('$top')).toBe('100');
expect(url.searchParams.get('branch')).toBe('refs/heads/develop');
});
it('extracts continuation token from body when header missing', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { value: [], continuationToken: 'body-token' },
headers: {},
});
const result = await listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
});
expect(result.continuationToken).toBe('body-token');
});
it('throws resource not found when API returns 404', async () => {
mockRestGet.mockResolvedValue({
statusCode: 404,
result: null,
headers: {},
});
await expect(
listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 999,
}),
).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
});
it('maps authentication errors', async () => {
mockRestGet.mockRejectedValue(new Error('401 Unauthorized'));
await expect(
listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
}),
).rejects.toBeInstanceOf(AzureDevOpsAuthenticationError);
});
it('wraps unexpected errors', async () => {
mockRestGet.mockRejectedValue(new Error('Boom'));
await expect(
listPipelineRuns(mockConnection, {
projectId: 'test-project',
pipelineId: 42,
}),
).rejects.toBeInstanceOf(AzureDevOpsError);
});
});
```
--------------------------------------------------------------------------------
/src/features/pull-requests/get-pull-request-checks/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { getPullRequestChecks } from './feature';
import { GitStatusState } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { PolicyEvaluationStatus } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
import { AzureDevOpsError } from '../../../shared/errors';
describe('getPullRequestChecks', () => {
it('returns status checks and policy evaluations with pipeline references', async () => {
const statusRecords: any[] = [
{
id: 10,
state: GitStatusState.Failed,
description: 'CI build',
context: { name: 'CI', genre: 'continuous-integration' },
targetUrl:
'https://dev.azure.com/org/project/_apis/pipelines/55/runs/123?view=results',
creationDate: new Date('2024-01-01T00:00:00Z'),
updatedDate: new Date('2024-01-01T01:00:00Z'),
},
{
id: 11,
state: GitStatusState.Succeeded,
description: 'Lint checks',
context: { name: 'Lint', genre: 'validation' },
targetUrl:
'https://dev.azure.com/org/project/_build/results?buildId=456&definitionId=789',
creationDate: new Date('2024-02-01T00:00:00Z'),
updatedDate: new Date('2024-02-01T01:00:00Z'),
},
];
const evaluationRecords: any[] = [
{
evaluationId: 'eval-1',
status: PolicyEvaluationStatus.Rejected,
configuration: {
id: 7,
revision: 3,
isBlocking: true,
isEnabled: true,
type: {
id: 'policy-guid',
displayName: 'Build',
},
settings: {
displayName: 'CI Build',
buildDefinitionId: 987,
},
},
context: {
buildId: 456,
targetUrl:
'https://dev.azure.com/org/project/_build/results?buildId=456',
message: 'Build failed',
},
},
];
const gitApi = {
getPullRequestStatuses: jest.fn().mockResolvedValue(statusRecords),
};
const policyApi = {
getPolicyEvaluations: jest.fn().mockResolvedValue(evaluationRecords),
};
const getProject = jest
.fn()
.mockResolvedValue({ id: 'project-guid', name: 'project' });
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue(gitApi),
getPolicyApi: jest.fn().mockResolvedValue(policyApi),
getCoreApi: jest.fn().mockResolvedValue({ getProject }),
};
const result = await getPullRequestChecks(mockConnection, {
projectId: 'project',
repositoryId: 'repo',
pullRequestId: 42,
});
expect(result.statuses).toHaveLength(2);
expect(result.statuses[0].state).toBe('failed');
expect(result.statuses[0].pipeline?.pipelineId).toBe(55);
expect(result.statuses[0].pipeline?.runId).toBe(123);
expect(result.statuses[1].pipeline?.buildId).toBe(456);
expect(result.statuses[1].pipeline?.definitionId).toBe(789);
expect(result.policyEvaluations).toHaveLength(1);
expect(result.policyEvaluations[0].status).toBe('rejected');
expect(result.policyEvaluations[0].pipeline?.definitionId).toBe(987);
expect(result.policyEvaluations[0].pipeline?.buildId).toBe(456);
expect(result.policyEvaluations[0].targetUrl).toContain('buildId=456');
expect(getProject).toHaveBeenCalledWith('project');
expect(gitApi.getPullRequestStatuses).toHaveBeenCalledWith(
'repo',
42,
'project-guid',
);
expect(policyApi.getPolicyEvaluations).toHaveBeenCalledWith(
'project-guid',
'vstfs:///CodeReview/CodeReviewId/project-guid/42',
);
});
it('re-throws Azure DevOps errors', async () => {
const azureError = new AzureDevOpsError('Azure failure');
const mockConnection: any = {
getGitApi: jest.fn().mockRejectedValue(azureError),
getPolicyApi: jest.fn(),
getCoreApi: jest.fn().mockResolvedValue({
getProject: jest.fn().mockResolvedValue({ id: 'project-guid' }),
}),
};
await expect(
getPullRequestChecks(mockConnection, {
projectId: 'project',
repositoryId: 'repo',
pullRequestId: 1,
}),
).rejects.toBe(azureError);
});
it('wraps unexpected errors', async () => {
const mockConnection: any = {
getGitApi: jest.fn().mockRejectedValue(new Error('boom')),
getPolicyApi: jest.fn(),
getCoreApi: jest.fn().mockResolvedValue({
getProject: jest.fn().mockResolvedValue({ id: 'project-guid' }),
}),
};
await expect(
getPullRequestChecks(mockConnection, {
projectId: 'project',
repositoryId: 'repo',
pullRequestId: 1,
}),
).rejects.toThrow('Failed to get pull request checks: boom');
});
it('uses the provided project GUID without fetching project metadata', async () => {
const mockConnection: any = {
getGitApi: jest.fn().mockResolvedValue({
getPullRequestStatuses: jest.fn().mockResolvedValue([]),
}),
getPolicyApi: jest.fn().mockResolvedValue({
getPolicyEvaluations: jest.fn().mockResolvedValue([]),
}),
getCoreApi: jest.fn(() => {
throw new Error('should not fetch project');
}),
};
await expect(
getPullRequestChecks(mockConnection, {
projectId: '12345678-1234-1234-1234-1234567890ab',
repositoryId: 'repo',
pullRequestId: 1,
}),
).resolves.toEqual({ statuses: [], policyEvaluations: [] });
expect(mockConnection.getGitApi).toHaveBeenCalled();
expect(mockConnection.getPolicyApi).toHaveBeenCalled();
});
});
```
--------------------------------------------------------------------------------
/src/features/pipelines/get-pipeline-run/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
RunResult,
RunState,
} from 'azure-devops-node-api/interfaces/PipelinesInterfaces';
import { getPipelineRun } from './feature';
import {
AzureDevOpsAuthenticationError,
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
} from '../../../shared/errors';
import { Run } from '../types';
describe('getPipelineRun unit', () => {
let mockConnection: WebApi;
let mockPipelinesApi: any;
let mockRestGet: jest.Mock;
let mockBuildApi: any;
const baseRun: Run = {
id: 200,
name: 'Run 200',
createdDate: new Date('2024-02-01T10:00:00Z'),
state: RunState.Completed,
result: RunResult.Succeeded,
url: 'https://dev.azure.com/org/project/_apis/pipelines/runs/200',
_links: {
web: { href: 'https://dev.azure.com/org/project/pipelines/run/200' },
},
pipeline: { id: 42 },
};
beforeEach(() => {
jest.resetAllMocks();
mockRestGet = jest.fn();
mockPipelinesApi = {
rest: { get: mockRestGet },
createRequestOptions: jest
.fn()
.mockReturnValue({ acceptHeader: 'application/json' }),
formatResponse: jest.fn().mockImplementation((result: any) => result),
};
mockBuildApi = {
getBuild: jest.fn().mockRejectedValue(new Error('not found')),
};
mockConnection = {
serverUrl: 'https://dev.azure.com/testorg',
getPipelinesApi: jest.fn().mockResolvedValue(mockPipelinesApi),
getBuildApi: jest.fn().mockResolvedValue(mockBuildApi),
} as unknown as WebApi;
});
it('returns run details', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: baseRun,
headers: {},
});
const run = await getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
});
expect(run).toEqual(baseRun);
expect(mockRestGet).toHaveBeenCalled();
const [requestUrl] = mockRestGet.mock.calls[0];
expect(requestUrl).toContain('/_apis/pipelines/runs/200');
expect(requestUrl).toContain('api-version=7.1');
});
it('uses build API to resolve pipeline id when not provided', async () => {
mockBuildApi.getBuild.mockResolvedValue({ definition: { id: 123 } });
mockRestGet.mockResolvedValue({
statusCode: 200,
result: baseRun,
headers: {},
});
await getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
});
expect(mockBuildApi.getBuild).toHaveBeenCalledWith('test-project', 200);
const [requestUrl] = mockRestGet.mock.calls[0];
expect(requestUrl).toContain('/pipelines/123/runs/200');
});
it('validates pipeline membership when provided', async () => {
mockRestGet.mockResolvedValueOnce({
statusCode: 200,
result: { ...baseRun, pipeline: { id: '42' } },
headers: {},
});
const run = await getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
pipelineId: 42,
});
expect(run.pipeline?.id).toBe('42');
});
it('throws resource not found when pipeline guard fails', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { ...baseRun, pipeline: { id: 99 } },
headers: {},
});
await expect(
getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
pipelineId: 42,
}),
).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
});
it('throws resource not found when pipeline information is missing but guard provided', async () => {
mockRestGet.mockResolvedValue({
statusCode: 200,
result: { ...baseRun, pipeline: undefined },
headers: {},
});
await expect(
getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
pipelineId: 42,
}),
).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
});
it('throws resource not found when API returns 404', async () => {
mockRestGet.mockResolvedValue({
statusCode: 404,
result: null,
headers: {},
});
await expect(
getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 404,
}),
).rejects.toBeInstanceOf(AzureDevOpsResourceNotFoundError);
});
it('falls back to generic run endpoint when pipeline-specific lookup fails', async () => {
mockRestGet
.mockResolvedValueOnce({ statusCode: 404, result: null, headers: {} })
.mockResolvedValueOnce({
statusCode: 200,
result: baseRun,
headers: {},
});
const run = await getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
pipelineId: 42,
});
expect(run).toEqual(baseRun);
expect(mockRestGet).toHaveBeenCalledTimes(2);
const [firstUrl] = mockRestGet.mock.calls[0];
const [secondUrl] = mockRestGet.mock.calls[1];
expect(firstUrl).toContain('/pipelines/42/runs/200');
expect(secondUrl).toContain('/pipelines/runs/200');
});
it('maps authentication errors', async () => {
mockRestGet.mockRejectedValue(new Error('Unauthorized 401'));
await expect(
getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
}),
).rejects.toBeInstanceOf(AzureDevOpsAuthenticationError);
});
it('wraps unexpected errors', async () => {
mockRestGet.mockRejectedValue(new Error('Something went wrong'));
await expect(
getPipelineRun(mockConnection, {
projectId: 'test-project',
runId: 200,
}),
).rejects.toBeInstanceOf(AzureDevOpsError);
});
});
```
--------------------------------------------------------------------------------
/src/features/search/search-work-items/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import axios from 'axios';
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
import {
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
AzureDevOpsValidationError,
AzureDevOpsPermissionError,
} from '../../../shared/errors';
import {
SearchWorkItemsOptions,
WorkItemSearchRequest,
WorkItemSearchResponse,
} from '../types';
/**
* Search for work items in Azure DevOps projects
*
* @param connection The Azure DevOps WebApi connection
* @param options Parameters for searching work items
* @returns Search results with work item details and highlights
*/
export async function searchWorkItems(
connection: WebApi,
options: SearchWorkItemsOptions,
): Promise<WorkItemSearchResponse> {
try {
// Prepare the search request
const searchRequest: WorkItemSearchRequest = {
searchText: options.searchText,
$skip: options.skip,
$top: options.top,
filters: {
...(options.projectId
? { 'System.TeamProject': [options.projectId] }
: {}),
...options.filters,
},
includeFacets: options.includeFacets,
$orderBy: options.orderBy,
};
// Get the authorization header from the connection
const authHeader = await getAuthorizationHeader();
// Extract organization and project from the connection URL
const { organization, project } = extractOrgAndProject(
connection,
options.projectId,
);
// Make the search API request
// If projectId is provided, include it in the URL, otherwise perform organization-wide search
const searchUrl = options.projectId
? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/workitemsearchresults?api-version=7.1`
: `https://almsearch.dev.azure.com/${organization}/_apis/search/workitemsearchresults?api-version=7.1`;
const searchResponse = await axios.post<WorkItemSearchResponse>(
searchUrl,
searchRequest,
{
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
},
);
return searchResponse.data;
} catch (error) {
// If it's already an AzureDevOpsError, rethrow it
if (error instanceof AzureDevOpsError) {
throw error;
}
// Handle axios errors
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.message || error.message;
if (status === 404) {
throw new AzureDevOpsResourceNotFoundError(
`Resource not found: ${message}`,
);
} else if (status === 400) {
throw new AzureDevOpsValidationError(
`Invalid request: ${message}`,
error.response?.data,
);
} else if (status === 401 || status === 403) {
throw new AzureDevOpsPermissionError(`Permission denied: ${message}`);
} else {
// For other axios errors, wrap in a generic AzureDevOpsError
throw new AzureDevOpsError(`Azure DevOps API error: ${message}`);
}
// This code is unreachable but TypeScript doesn't know that
}
// Otherwise, wrap it in a generic error
throw new AzureDevOpsError(
`Failed to search work items: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Extract organization and project from the connection URL
*
* @param connection The Azure DevOps WebApi connection
* @param projectId The project ID or name (optional)
* @returns The organization and project
*/
function extractOrgAndProject(
connection: WebApi,
projectId?: string,
): { organization: string; project: string } {
// Extract organization from the connection URL
const url = connection.serverUrl;
const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/);
const organization = match ? match[1] : '';
if (!organization) {
throw new AzureDevOpsValidationError(
'Could not extract organization from connection URL',
);
}
return {
organization,
project: projectId || '',
};
}
/**
* Get the authorization header from the connection
*
* @returns The authorization header
*/
async function getAuthorizationHeader(): Promise<string> {
try {
// For PAT authentication, we can construct the header directly
if (
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' &&
process.env.AZURE_DEVOPS_PAT
) {
// For PAT auth, we can construct the Basic auth header directly
const token = process.env.AZURE_DEVOPS_PAT;
const base64Token = Buffer.from(`:${token}`).toString('base64');
return `Basic ${base64Token}`;
}
// For Azure Identity / Azure CLI auth, we need to get a token
// using the Azure DevOps resource ID
// Choose the appropriate credential based on auth method
const credential =
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
? new AzureCliCredential()
: new DefaultAzureCredential();
// Azure DevOps resource ID for token acquisition
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
// Get token for Azure DevOps
const token = await credential.getToken(
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
);
if (!token || !token.token) {
throw new Error('Failed to acquire token for Azure DevOps');
}
return `Bearer ${token.token}`;
} catch (error) {
throw new AzureDevOpsValidationError(
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/docs/tools/wiki.md:
--------------------------------------------------------------------------------
```markdown
# Azure DevOps Wiki Tools
This document describes the tools available for working with Azure DevOps wikis.
## get_wikis
Lists all wikis in a project or organization.
### Description
The `get_wikis` tool retrieves all wikis available in a specified Azure DevOps project or organization. This is useful for discovering which wikis are available before working with specific wiki pages.
### Parameters
- `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used.
- `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used.
```json
{
"organizationId": "MyOrganization",
"projectId": "MyProject"
}
```
### Response
The tool returns an array of wiki objects, each containing:
- `id`: The unique identifier of the wiki
- `name`: The name of the wiki
- `url`: The URL of the wiki
- Other wiki properties such as `remoteUrl` and `type`
Example response:
```json
[
{
"id": "wiki1-id",
"name": "MyWiki",
"type": "projectWiki",
"url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki",
"remoteUrl": "https://dev.azure.com/MyOrganization/MyProject/_git/MyWiki"
}
]
```
## get_wiki_page
Gets the content of a specific wiki page.
### Description
The `get_wiki_page` tool retrieves the content of a specified wiki page as plain text. This is useful for viewing the content of wiki pages programmatically.
### Parameters
- `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used.
- `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used.
- `wikiId` (required): The ID or name of the wiki containing the page.
- `pagePath` (required): The path of the page within the wiki (e.g., "/Home" or "/Folder/Page").
```json
{
"organizationId": "MyOrganization",
"projectId": "MyProject",
"wikiId": "MyWiki",
"pagePath": "/Home"
}
```
### Response
The tool returns the content of the wiki page as a string in markdown format.
Example response:
```markdown
# Welcome to the Wiki
This is the home page of the wiki.
## Getting Started
Here are some links to help you get started:
- [Documentation](/Documentation)
- [Tutorials](/Tutorials)
- [FAQ](/FAQ)
```
### Error Handling
The tool may throw the following errors:
- `AzureDevOpsResourceNotFoundError`: If the specified wiki or page does not exist
- `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki
- General errors: If other unexpected errors occur during the request
### Example Usage
```typescript
// Example MCP client call
const result = await mcpClient.callTool('get_wiki_page', {
projectId: 'MyProject',
wikiId: 'MyWiki',
pagePath: '/Home'
});
console.log(result);
```
### Implementation Details
This tool uses the Azure DevOps REST API to retrieve the wiki page content with the `Accept: text/plain` header to get the content directly in text format. The page path is properly encoded to handle spaces and special characters in the URL.
## list_wiki_pages
Lists all pages within a specified Azure DevOps wiki.
### Description
The `list_wiki_pages` tool retrieves a list of all pages within a specified wiki. It returns summary information for each page, including the page ID, path, URL, and order. This is useful for discovering the structure and contents of a wiki before working with specific pages.
### Parameters
- `organizationId` (optional): The ID or name of the organization. If not provided, the default organization from environment settings will be used.
- `projectId` (optional): The ID or name of the project. If not provided, the default project from environment settings will be used.
- `wikiId` (required): The ID or name of the wiki to list pages from.
```json
{
"organizationId": "MyOrganization",
"projectId": "MyProject",
"wikiId": "MyWiki"
}
```
### Response
The tool returns an array of wiki page summary objects, each containing:
- `id`: The unique numeric identifier of the page
- `path`: The path of the page within the wiki (e.g., "/Home" or "/Folder/Page")
- `url`: The URL to access the page (optional)
- `order`: The display order of the page (optional)
Example response:
```json
[
{
"id": 1,
"path": "/Home",
"url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/1/Home",
"order": 0
},
{
"id": 2,
"path": "/Documentation",
"url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/2/Documentation",
"order": 1
},
{
"id": 3,
"path": "/Documentation/Getting-Started",
"url": "https://dev.azure.com/MyOrganization/MyProject/_wiki/wikis/MyWiki/3/Getting-Started",
"order": 2
}
]
```
### Error Handling
The tool may throw the following errors:
- `AzureDevOpsResourceNotFoundError`: If the specified wiki does not exist
- `AzureDevOpsPermissionError`: If the authenticated user does not have permission to access the wiki
- `AzureDevOpsError`: If other unexpected errors occur during the request
### Example Usage
```typescript
// Example MCP client call
const result = await mcpClient.callTool('list_wiki_pages', {
projectId: 'MyProject',
wikiId: 'MyWiki'
});
console.log(result);
```
### Implementation Details
This tool uses the Azure DevOps REST API to retrieve the list of pages within a wiki. The response is mapped to provide a consistent interface with page ID, path, URL, and order information.
```
--------------------------------------------------------------------------------
/docs/azure-identity-authentication.md:
--------------------------------------------------------------------------------
```markdown
# Azure Identity Authentication for Azure DevOps MCP Server
This guide explains how to use Azure Identity authentication with the Azure DevOps MCP Server.
## Overview
Azure Identity authentication lets you use your existing Azure credentials to authenticate with Azure DevOps, instead of creating and managing Personal Access Tokens (PATs). This approach offers several benefits:
- **Unified authentication**: Use the same credentials for Azure and Azure DevOps
- **Enhanced security**: Support for managed identities and client certificates
- **Flexible credential types**: Multiple options for different environments
- **Automatic token management**: Handles token acquisition and renewal
## Credential Types
The Azure DevOps MCP Server supports multiple credential types through the Azure Identity SDK:
### DefaultAzureCredential
This credential type attempts multiple authentication methods in sequence until one succeeds:
1. Environment variables (EnvironmentCredential)
2. Managed Identity (ManagedIdentityCredential)
3. Azure CLI (AzureCliCredential)
4. Visual Studio Code (VisualStudioCodeCredential)
5. Azure PowerShell (AzurePowerShellCredential)
It's a great option for applications that need to work across different environments without code changes.
### AzureCliCredential
This credential type uses your Azure CLI login. It's perfect for local development when you're already using the Azure CLI.
## Configuration
### Environment Variables
To use Azure Identity authentication, set the following environment variables:
```bash
# Required
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
AZURE_DEVOPS_AUTH_METHOD=azure-identity
# Optional
AZURE_DEVOPS_DEFAULT_PROJECT=your-project-name
```
For service principal authentication, add these environment variables:
```bash
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
```
### Use with Claude Desktop/Cursor AI
Add the following to your configuration file:
```json
{
"mcpServers": {
"azureDevOps": {
"command": "npx",
"args": ["-y", "@tiberriver256/mcp-server-azure-devops"],
"env": {
"AZURE_DEVOPS_ORG_URL": "https://dev.azure.com/your-organization",
"AZURE_DEVOPS_AUTH_METHOD": "azure-identity",
"AZURE_DEVOPS_DEFAULT_PROJECT": "your-project-name"
}
}
}
}
```
## Authentication Methods
### Method 1: Using Azure CLI
1. Install the Azure CLI from [here](https://docs.microsoft.com/cli/azure/install-azure-cli)
2. Log in to Azure:
```bash
az login
```
3. Set up your environment variables:
```bash
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
AZURE_DEVOPS_AUTH_METHOD=azure-identity
```
### Method 2: Using Service Principal
1. Create a service principal in Azure AD:
```bash
az ad sp create-for-rbac --name "MyAzureDevOpsApp"
```
2. Grant the service principal access to your Azure DevOps organization
3. Set up your environment variables:
```bash
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
AZURE_DEVOPS_AUTH_METHOD=azure-identity
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
```
### Method 3: Using Managed Identity (for Azure-hosted applications)
1. Enable managed identity for your Azure resource (VM, App Service, etc.)
2. Grant the managed identity access to your Azure DevOps organization
3. Set up your environment variables:
```bash
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/your-organization
AZURE_DEVOPS_AUTH_METHOD=azure-identity
```
## Troubleshooting
### Common Issues
#### Failed to acquire token
```
Error: Failed to authenticate with Azure Identity: CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token
```
**Possible solutions:**
- Ensure you're logged in with `az login`
- Check if your managed identity is correctly configured
- Verify that service principal credentials are correct
#### Permission issues
```
Error: Failed to authenticate with Azure Identity: AuthorizationFailed: The client does not have authorization to perform action
```
**Possible solutions:**
- Ensure your identity has the necessary permissions in Azure DevOps
- Check if you need to add your identity to specific Azure DevOps project(s)
#### Network issues
```
Error: Failed to authenticate with Azure Identity: ClientAuthError: Interaction required
```
**Possible solutions:**
- Check your network connectivity
- Verify that your firewall allows connections to Azure services
## Best Practices
1. **Choose the right credential type for your environment**:
- For local development: Azure CLI credential
- For CI/CD pipelines: Service principal
- For Azure-hosted applications: Managed identity
2. **Follow the principle of least privilege**:
- Only grant the permissions needed for your use case
- Regularly audit and review permissions
3. **Rotate credentials regularly**:
- For service principals, rotate client secrets periodically
- Use certificate-based authentication when possible for enhanced security
## Examples
### Basic configuration with Azure CLI
```bash
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
AZURE_DEVOPS_AUTH_METHOD=azure-identity
AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
```
### Service principal authentication
```bash
AZURE_DEVOPS_ORG_URL=https://dev.azure.com/mycompany
AZURE_DEVOPS_AUTH_METHOD=azure-identity
AZURE_DEVOPS_DEFAULT_PROJECT=MyProject
AZURE_TENANT_ID=00000000-0000-0000-0000-000000000000
AZURE_CLIENT_ID=11111111-1111-1111-1111-111111111111
AZURE_CLIENT_SECRET=your-client-secret
```
```
--------------------------------------------------------------------------------
/src/features/pull-requests/add-pull-request-comment/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { addPullRequestComment } from './feature';
import { listPullRequests } from '../list-pull-requests/feature';
import {
getTestConnection,
shouldSkipIntegrationTest,
} from '@/shared/test/test-helpers';
describe('addPullRequestComment integration', () => {
let connection: WebApi | null = null;
let projectName: string;
let repositoryName: string;
let pullRequestId: number;
// Generate unique identifiers using timestamp for comment content
const timestamp = Date.now();
const randomSuffix = Math.floor(Math.random() * 1000);
beforeAll(async () => {
// Get a real connection using environment variables
connection = await getTestConnection();
// Set up project and repository names from environment
projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || '';
// Skip setup if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
return;
}
try {
// Find an active pull request to use for testing
const pullRequests = await listPullRequests(
connection,
projectName,
repositoryName,
{
projectId: projectName,
repositoryId: repositoryName,
status: 'active',
top: 1,
},
);
if (!pullRequests || pullRequests.value.length === 0) {
throw new Error('No active pull requests found for testing');
}
pullRequestId = pullRequests.value[0].pullRequestId!;
console.log(`Using existing pull request #${pullRequestId} for testing`);
} catch (error) {
console.error('Error in test setup:', error);
throw error;
}
});
test('should add a new comment thread to pull request', async () => {
// Skip if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
console.log('Skipping test due to missing connection');
return;
}
// Skip if repository name is not defined
if (!repositoryName) {
console.log('Skipping test due to missing repository name');
return;
}
const commentContent = `Test comment ${timestamp}-${randomSuffix}`;
const result = await addPullRequestComment(
connection,
projectName,
repositoryName,
pullRequestId,
{
projectId: projectName,
repositoryId: repositoryName,
pullRequestId,
content: commentContent,
status: 'active',
},
);
// Verify the comment was created
expect(result.comment).toBeDefined();
expect(result.comment.content).toBe(commentContent);
expect(result.thread).toBeDefined();
expect(result.thread!.status).toBe('active'); // Transformed to string
}, 30000); // 30 second timeout for integration test
test('should add a file comment to pull request', async () => {
// Skip if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
console.log('Skipping test due to missing connection');
return;
}
// Skip if repository name is not defined
if (!repositoryName) {
console.log('Skipping test due to missing repository name');
return;
}
const commentContent = `File comment ${timestamp}-${randomSuffix}`;
const filePath = '/README.md'; // Assuming README.md exists in the repo
const lineNumber = 1;
const result = await addPullRequestComment(
connection,
projectName,
repositoryName,
pullRequestId,
{
projectId: projectName,
repositoryId: repositoryName,
pullRequestId,
content: commentContent,
filePath,
lineNumber,
status: 'active',
},
);
// Verify the file comment was created
expect(result.comment).toBeDefined();
expect(result.comment.content).toBe(commentContent);
expect(result.thread).toBeDefined();
expect(result.thread!.threadContext).toBeDefined();
expect(result.thread!.threadContext!.filePath).toBe(filePath);
expect(result.thread!.threadContext!.rightFileStart!.line).toBe(lineNumber);
}, 30000); // 30 second timeout for integration test
test('should add a reply to an existing comment thread', async () => {
// Skip if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
console.log('Skipping test due to missing connection');
return;
}
// Skip if repository name is not defined
if (!repositoryName) {
console.log('Skipping test due to missing repository name');
return;
}
// First create a thread
const initialComment = await addPullRequestComment(
connection,
projectName,
repositoryName,
pullRequestId,
{
projectId: projectName,
repositoryId: repositoryName,
pullRequestId,
content: `Initial comment ${timestamp}-${randomSuffix}`,
status: 'active',
},
);
const threadId = initialComment.thread!.id!;
const replyContent = `Reply comment ${timestamp}-${randomSuffix}`;
// Add a reply to the thread
const result = await addPullRequestComment(
connection,
projectName,
repositoryName,
pullRequestId,
{
projectId: projectName,
repositoryId: repositoryName,
pullRequestId,
content: replyContent,
threadId,
},
);
// Verify the reply was created
expect(result.comment).toBeDefined();
expect(result.comment.content).toBe(replyContent);
expect(result.thread).toBeUndefined(); // No thread returned for replies
}, 30000); // 30 second timeout for integration test
});
```
--------------------------------------------------------------------------------
/src/features/work-items/create-work-item/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { createWorkItem } from './feature';
import {
getTestConnection,
shouldSkipIntegrationTest,
} from '@/shared/test/test-helpers';
import { CreateWorkItemOptions } from '../types';
describe('createWorkItem integration', () => {
let connection: WebApi | null = null;
beforeAll(async () => {
// Get a real connection using environment variables
connection = await getTestConnection();
});
test('should create a new work item in Azure DevOps', async () => {
// Skip if no connection is available
if (shouldSkipIntegrationTest()) {
return;
}
// This connection must be available if we didn't skip
if (!connection) {
throw new Error(
'Connection should be available when test is not skipped',
);
}
// Create a unique title using timestamp to avoid conflicts
const uniqueTitle = `Test Work Item ${new Date().toISOString()}`;
// For a true integration test, use a real project
const projectName =
process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
const workItemType = 'Task'; // Assumes 'Task' type exists in the project
const options: CreateWorkItemOptions = {
title: uniqueTitle,
description: 'This is a test work item created by an integration test',
priority: 2,
};
// Act - make an actual API call to Azure DevOps
const result = await createWorkItem(
connection,
projectName,
workItemType,
options,
);
// Assert on the actual response
expect(result).toBeDefined();
expect(result.id).toBeDefined();
// Verify fields match what we set
expect(result.fields).toBeDefined();
if (result.fields) {
expect(result.fields['System.Title']).toBe(uniqueTitle);
expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(2);
}
});
test('should create a work item with additional fields', async () => {
// Skip if no connection is available
if (shouldSkipIntegrationTest()) {
return;
}
// This connection must be available if we didn't skip
if (!connection) {
throw new Error(
'Connection should be available when test is not skipped',
);
}
// Create a unique title using timestamp to avoid conflicts
const uniqueTitle = `Test Work Item with Fields ${new Date().toISOString()}`;
// For a true integration test, use a real project
const projectName =
process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
const workItemType = 'Task';
const options: CreateWorkItemOptions = {
title: uniqueTitle,
description: 'This is a test work item with additional fields',
priority: 1,
additionalFields: {
'System.Tags': 'Integration Test,Automated',
},
};
// Act - make an actual API call to Azure DevOps
const result = await createWorkItem(
connection,
projectName,
workItemType,
options,
);
// Assert on the actual response
expect(result).toBeDefined();
expect(result.id).toBeDefined();
// Verify fields match what we set
expect(result.fields).toBeDefined();
if (result.fields) {
expect(result.fields['System.Title']).toBe(uniqueTitle);
expect(result.fields['Microsoft.VSTS.Common.Priority']).toBe(1);
// Just check that tags contain both values, order may vary
expect(result.fields['System.Tags']).toContain('Integration Test');
expect(result.fields['System.Tags']).toContain('Automated');
}
});
test('should create a child work item with parent-child relationship', async () => {
// Skip if no connection is available
if (shouldSkipIntegrationTest()) {
return;
}
// This connection must be available if we didn't skip
if (!connection) {
throw new Error(
'Connection should be available when test is not skipped',
);
}
// For a true integration test, use a real project
const projectName =
process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
// First, create a parent work item (User Story)
const parentTitle = `Parent Story ${new Date().toISOString()}`;
const parentOptions: CreateWorkItemOptions = {
title: parentTitle,
description: 'This is a parent user story',
};
const parentResult = await createWorkItem(
connection,
projectName,
'User Story', // Assuming User Story type exists
parentOptions,
);
expect(parentResult).toBeDefined();
expect(parentResult.id).toBeDefined();
const parentId = parentResult.id;
// Now create a child work item (Task) with a link to the parent
const childTitle = `Child Task ${new Date().toISOString()}`;
const childOptions: CreateWorkItemOptions = {
title: childTitle,
description: 'This is a child task of a user story',
parentId: parentId, // Reference to parent work item
};
const childResult = await createWorkItem(
connection,
projectName,
'Task',
childOptions,
);
// Assert the child work item was created
expect(childResult).toBeDefined();
expect(childResult.id).toBeDefined();
// Now verify the parent-child relationship
// We would need to fetch the relations, but for now we'll just assert
// that the response indicates a relationship was created
expect(childResult.relations).toBeDefined();
// Check that at least one relation exists that points to our parent
const parentRelation = childResult.relations?.find(
(relation) =>
relation.rel === 'System.LinkTypes.Hierarchy-Reverse' &&
relation.url &&
relation.url.includes(`/${parentId}`),
);
expect(parentRelation).toBeDefined();
});
});
```
--------------------------------------------------------------------------------
/src/features/wikis/create-wiki/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
AzureDevOpsValidationError,
AzureDevOpsPermissionError,
} from '../../../shared/errors';
import { createWiki } from './feature';
import { WikiType } from './schema';
import { getWikiClient } from '../../../clients/azure-devops';
// Mock the WikiClient
jest.mock('../../../clients/azure-devops');
describe('createWiki unit', () => {
// Mock WikiClient
const mockWikiClient = {
createWiki: jest.fn(),
};
// Mock WebApi connection (kept for backward compatibility)
const mockConnection = {} as WebApi;
beforeEach(() => {
// Clear mock calls between tests
jest.clearAllMocks();
// Setup mock response for getWikiClient
(getWikiClient as jest.Mock).mockResolvedValue(mockWikiClient);
});
test('should create a project wiki', async () => {
// Mock data
const mockWiki = {
id: 'wiki1',
name: 'Project Wiki',
projectId: 'project1',
remoteUrl: 'https://example.com/wiki1',
url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki1',
type: 'projectWiki',
repositoryId: 'repo1',
mappedPath: '/',
};
// Setup mock response
mockWikiClient.createWiki.mockResolvedValue(mockWiki);
// Call the function
const result = await createWiki(mockConnection, {
name: 'Project Wiki',
projectId: 'project1',
});
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', {
name: 'Project Wiki',
projectId: 'project1',
type: WikiType.ProjectWiki,
});
expect(result).toEqual(mockWiki);
});
test('should create a code wiki', async () => {
// Mock data
const mockWiki = {
id: 'wiki2',
name: 'Code Wiki',
projectId: 'project1',
repositoryId: 'repo1',
mappedPath: '/docs',
remoteUrl: 'https://example.com/wiki2',
url: 'https://dev.azure.com/org/project/_wiki/wikis/wiki2',
type: 'codeWiki',
};
// Setup mock response
mockWikiClient.createWiki.mockResolvedValue(mockWiki);
// Call the function
const result = await createWiki(mockConnection, {
name: 'Code Wiki',
projectId: 'project1',
type: WikiType.CodeWiki,
repositoryId: 'repo1',
mappedPath: '/docs',
});
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalledWith('project1', {
name: 'Code Wiki',
projectId: 'project1',
type: WikiType.CodeWiki,
repositoryId: 'repo1',
mappedPath: '/docs',
version: {
version: 'main',
versionType: 'branch' as const,
},
});
expect(result).toEqual(mockWiki);
});
test('should throw validation error when repository ID is missing for code wiki', async () => {
// Call the function and expect it to throw
await expect(
createWiki(mockConnection, {
name: 'Code Wiki',
projectId: 'project1',
type: WikiType.CodeWiki,
// repositoryId is missing
}),
).rejects.toThrow(AzureDevOpsValidationError);
// Assertions
expect(getWikiClient).not.toHaveBeenCalled();
expect(mockWikiClient.createWiki).not.toHaveBeenCalled();
});
test('should handle project not found error', async () => {
// Setup mock to throw an error
mockWikiClient.createWiki.mockRejectedValue(
new AzureDevOpsResourceNotFoundError('Project not found'),
);
// Call the function and expect it to throw
await expect(
createWiki(mockConnection, {
name: 'Project Wiki',
projectId: 'nonExistentProject',
}),
).rejects.toThrow(AzureDevOpsResourceNotFoundError);
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalled();
});
test('should handle repository not found error', async () => {
// Setup mock to throw an error
mockWikiClient.createWiki.mockRejectedValue(
new AzureDevOpsResourceNotFoundError('Repository not found'),
);
// Call the function and expect it to throw
await expect(
createWiki(mockConnection, {
name: 'Code Wiki',
projectId: 'project1',
type: WikiType.CodeWiki,
repositoryId: 'nonExistentRepo',
}),
).rejects.toThrow(AzureDevOpsResourceNotFoundError);
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalled();
});
test('should handle permission error', async () => {
// Setup mock to throw an error
mockWikiClient.createWiki.mockRejectedValue(
new AzureDevOpsPermissionError('You do not have permission'),
);
// Call the function and expect it to throw
await expect(
createWiki(mockConnection, {
name: 'Project Wiki',
projectId: 'project1',
}),
).rejects.toThrow(AzureDevOpsPermissionError);
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalled();
});
test('should handle generic errors', async () => {
// Setup mock to throw an error
mockWikiClient.createWiki.mockRejectedValue(new Error('Unknown error'));
// Call the function and expect it to throw
await expect(
createWiki(mockConnection, {
name: 'Project Wiki',
projectId: 'project1',
}),
).rejects.toThrow(AzureDevOpsError);
// Assertions
expect(getWikiClient).toHaveBeenCalledWith({ organizationId: undefined });
expect(mockWikiClient.createWiki).toHaveBeenCalled();
});
});
```
--------------------------------------------------------------------------------
/src/features/repositories/create-commit/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import {
GitVersionType,
GitRefUpdate,
GitChange,
VersionControlChangeType,
ItemContentType,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import { applyPatch, parsePatch, createTwoFilesPatch } from 'diff';
import { AzureDevOpsError } from '../../../shared/errors';
import { CreateCommitOptions } from '../types';
async function streamToString(stream: NodeJS.ReadableStream): Promise<string> {
const chunks: Buffer[] = [];
return await new Promise<string>((resolve, reject) => {
stream.on('data', (c) => chunks.push(Buffer.from(c)));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', (err) => reject(err));
});
}
/**
* Create a commit with multiple file changes
*/
export async function createCommit(
connection: WebApi,
options: CreateCommitOptions,
): Promise<void> {
try {
const gitApi = await connection.getGitApi();
const branch = await gitApi.getBranch(
options.repositoryId,
options.branchName,
options.projectId,
);
const baseCommit = branch?.commit?.commitId;
if (!baseCommit) {
throw new AzureDevOpsError(`Branch '${options.branchName}' not found`);
}
const changes: GitChange[] = [];
for (const file of options.changes) {
// Handle search/replace format by generating a patch
let patchString = file.patch;
if (
!patchString &&
file.search !== undefined &&
file.replace !== undefined
) {
if (!file.path) {
throw new AzureDevOpsError(
'path is required when using search/replace format',
);
}
// Fetch current file content
let currentContent = '';
try {
const stream = await gitApi.getItemContent(
options.repositoryId,
file.path,
options.projectId,
undefined,
undefined,
undefined,
undefined,
false,
{ version: options.branchName, versionType: GitVersionType.Branch },
true,
);
currentContent = stream ? await streamToString(stream) : '';
} catch {
// File might not exist (new file scenario) - treat as empty
currentContent = '';
}
// Perform the replacement
if (!currentContent.includes(file.search)) {
throw new AzureDevOpsError(
`Search string not found in ${file.path}. The file may have been modified since you last read it.`,
);
}
const newContent = currentContent.replace(file.search, file.replace);
// Generate proper unified diff
patchString = createTwoFilesPatch(
file.path,
file.path,
currentContent,
newContent,
undefined,
undefined,
);
}
if (!patchString) {
throw new AzureDevOpsError(
'Either patch or both search and replace must be provided for each change',
);
}
const patches = parsePatch(patchString);
if (patches.length !== 1) {
throw new AzureDevOpsError(
`Expected a single file diff for change but received ${patches.length}`,
);
}
const patch = patches[0];
const normalizePath = (path?: string | null): string | undefined => {
if (!path || path === '/dev/null') {
return undefined;
}
return path.replace(/^a\//, '').replace(/^b\//, '');
};
const oldPath = normalizePath(patch.oldFileName);
const newPath = normalizePath(patch.newFileName);
const targetPath = file.path ?? newPath ?? oldPath;
if (!targetPath) {
throw new AzureDevOpsError(
'Unable to determine target path for change',
);
}
if (oldPath && newPath && oldPath !== newPath) {
throw new AzureDevOpsError(
`Renaming files is not supported (attempted ${oldPath} -> ${newPath})`,
);
}
let originalContent = '';
if (oldPath) {
const stream = await gitApi.getItemContent(
options.repositoryId,
oldPath,
options.projectId,
undefined,
undefined,
undefined,
undefined,
false,
{ version: options.branchName, versionType: GitVersionType.Branch },
true,
);
originalContent = stream ? await streamToString(stream) : '';
}
const patchedContent = applyPatch(originalContent, patch);
if (patchedContent === false) {
throw new AzureDevOpsError(
`Failed to apply diff for ${targetPath}. Please ensure the patch is up to date with the branch head.`,
);
}
if (!newPath) {
changes.push({
changeType: VersionControlChangeType.Delete,
item: { path: targetPath },
});
continue;
}
const changeType = oldPath
? VersionControlChangeType.Edit
: VersionControlChangeType.Add;
changes.push({
changeType,
item: { path: targetPath },
newContent: {
content: patchedContent,
contentType: ItemContentType.RawText,
},
});
}
const commit = {
comment: options.commitMessage,
changes,
};
const refUpdate: GitRefUpdate = {
name: `refs/heads/${options.branchName}`,
oldObjectId: baseCommit,
};
await gitApi.createPush(
{ commits: [commit], refUpdates: [refUpdate] },
options.repositoryId,
options.projectId,
);
} catch (error) {
if (error instanceof AzureDevOpsError) {
throw error;
}
throw new Error(
`Failed to create commit: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/pull-requests/index.ts:
--------------------------------------------------------------------------------
```typescript
export * from './schemas';
export * from './types';
export * from './create-pull-request';
export * from './list-pull-requests';
export * from './get-pull-request-comments';
export * from './add-pull-request-comment';
export * from './update-pull-request';
export * from './get-pull-request-changes';
export * from './get-pull-request-checks';
// Export tool definitions
export * from './tool-definitions';
// New exports for request handling
import { CallToolRequest } from '@modelcontextprotocol/sdk/types.js';
import { WebApi } from 'azure-devops-node-api';
import {
RequestIdentifier,
RequestHandler,
} from '../../shared/types/request-handler';
import { defaultProject } from '../../utils/environment';
import {
CreatePullRequestSchema,
ListPullRequestsSchema,
GetPullRequestCommentsSchema,
AddPullRequestCommentSchema,
UpdatePullRequestSchema,
GetPullRequestChangesSchema,
GetPullRequestChecksSchema,
createPullRequest,
listPullRequests,
getPullRequestComments,
addPullRequestComment,
updatePullRequest,
getPullRequestChanges,
getPullRequestChecks,
} from './';
/**
* Checks if the request is for the pull requests feature
*/
export const isPullRequestsRequest: RequestIdentifier = (
request: CallToolRequest,
): boolean => {
const toolName = request.params.name;
return [
'create_pull_request',
'list_pull_requests',
'get_pull_request_comments',
'add_pull_request_comment',
'update_pull_request',
'get_pull_request_changes',
'get_pull_request_checks',
].includes(toolName);
};
/**
* Handles pull requests feature requests
*/
export const handlePullRequestsRequest: RequestHandler = async (
connection: WebApi,
request: CallToolRequest,
): Promise<{ content: Array<{ type: string; text: string }> }> => {
switch (request.params.name) {
case 'create_pull_request': {
const args = CreatePullRequestSchema.parse(request.params.arguments);
const result = await createPullRequest(
connection,
args.projectId ?? defaultProject,
args.repositoryId,
args,
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'list_pull_requests': {
const params = ListPullRequestsSchema.parse(request.params.arguments);
const result = await listPullRequests(
connection,
params.projectId ?? defaultProject,
params.repositoryId,
{
projectId: params.projectId ?? defaultProject,
repositoryId: params.repositoryId,
status: params.status,
creatorId: params.creatorId,
reviewerId: params.reviewerId,
sourceRefName: params.sourceRefName,
targetRefName: params.targetRefName,
top: params.top,
skip: params.skip,
pullRequestId: params.pullRequestId,
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pull_request_comments': {
const params = GetPullRequestCommentsSchema.parse(
request.params.arguments,
);
const result = await getPullRequestComments(
connection,
params.projectId ?? defaultProject,
params.repositoryId,
params.pullRequestId,
{
projectId: params.projectId ?? defaultProject,
repositoryId: params.repositoryId,
pullRequestId: params.pullRequestId,
threadId: params.threadId,
includeDeleted: params.includeDeleted,
top: params.top,
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'add_pull_request_comment': {
const params = AddPullRequestCommentSchema.parse(
request.params.arguments,
);
const result = await addPullRequestComment(
connection,
params.projectId ?? defaultProject,
params.repositoryId,
params.pullRequestId,
{
projectId: params.projectId ?? defaultProject,
repositoryId: params.repositoryId,
pullRequestId: params.pullRequestId,
content: params.content,
threadId: params.threadId,
parentCommentId: params.parentCommentId,
filePath: params.filePath,
lineNumber: params.lineNumber,
status: params.status,
},
);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'update_pull_request': {
const params = UpdatePullRequestSchema.parse(request.params.arguments);
const fixedParams = {
...params,
projectId: params.projectId ?? defaultProject,
};
const result = await updatePullRequest(fixedParams);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pull_request_changes': {
const params = GetPullRequestChangesSchema.parse(
request.params.arguments,
);
const result = await getPullRequestChanges(connection, {
projectId: params.projectId ?? defaultProject,
repositoryId: params.repositoryId,
pullRequestId: params.pullRequestId,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
case 'get_pull_request_checks': {
const params = GetPullRequestChecksSchema.parse(request.params.arguments);
const result = await getPullRequestChecks(connection, {
projectId: params.projectId ?? defaultProject,
repositoryId: params.repositoryId,
pullRequestId: params.pullRequestId,
});
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
default:
throw new Error(`Unknown pull requests tool: ${request.params.name}`);
}
};
```
--------------------------------------------------------------------------------
/src/features/search/search-wiki/feature.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import axios from 'axios';
import { DefaultAzureCredential, AzureCliCredential } from '@azure/identity';
import {
AzureDevOpsError,
AzureDevOpsResourceNotFoundError,
AzureDevOpsValidationError,
AzureDevOpsPermissionError,
} from '../../../shared/errors';
import {
SearchWikiOptions,
WikiSearchRequest,
WikiSearchResponse,
} from '../types';
/**
* Search for wiki pages in Azure DevOps projects
*
* @param connection The Azure DevOps WebApi connection
* @param options Parameters for searching wiki pages
* @returns Search results for wiki pages
*/
export async function searchWiki(
connection: WebApi,
options: SearchWikiOptions,
): Promise<WikiSearchResponse> {
try {
// Prepare the search request
const searchRequest: WikiSearchRequest = {
searchText: options.searchText,
$skip: options.skip,
$top: options.top,
filters: options.projectId
? {
Project: [options.projectId],
}
: {},
includeFacets: options.includeFacets,
};
// Add custom filters if provided
if (
options.filters &&
options.filters.Project &&
options.filters.Project.length > 0
) {
if (!searchRequest.filters) {
searchRequest.filters = {};
}
if (!searchRequest.filters.Project) {
searchRequest.filters.Project = [];
}
searchRequest.filters.Project = [
...(searchRequest.filters.Project || []),
...options.filters.Project,
];
}
// Get the authorization header from the connection
const authHeader = await getAuthorizationHeader();
// Extract organization and project from the connection URL
const { organization, project } = extractOrgAndProject(
connection,
options.projectId,
);
// Make the search API request
// If projectId is provided, include it in the URL, otherwise perform organization-wide search
const searchUrl = options.projectId
? `https://almsearch.dev.azure.com/${organization}/${project}/_apis/search/wikisearchresults?api-version=7.1`
: `https://almsearch.dev.azure.com/${organization}/_apis/search/wikisearchresults?api-version=7.1`;
const searchResponse = await axios.post<WikiSearchResponse>(
searchUrl,
searchRequest,
{
headers: {
Authorization: authHeader,
'Content-Type': 'application/json',
},
},
);
return searchResponse.data;
} catch (error) {
// If it's already an AzureDevOpsError, rethrow it
if (error instanceof AzureDevOpsError) {
throw error;
}
// Handle axios errors
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const message = error.response?.data?.message || error.message;
if (status === 404) {
throw new AzureDevOpsResourceNotFoundError(
`Resource not found: ${message}`,
);
} else if (status === 400) {
throw new AzureDevOpsValidationError(
`Invalid request: ${message}`,
error.response?.data,
);
} else if (status === 401 || status === 403) {
throw new AzureDevOpsPermissionError(`Permission denied: ${message}`);
} else {
// For other axios errors, wrap in a generic AzureDevOpsError
throw new AzureDevOpsError(`Azure DevOps API error: ${message}`);
}
// This return is never reached but helps TypeScript understand the control flow
return null as never;
}
// Otherwise, wrap it in a generic error
throw new AzureDevOpsError(
`Failed to search wiki: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Extract organization and project from the connection URL
*
* @param connection The Azure DevOps WebApi connection
* @param projectId The project ID or name (optional)
* @returns The organization and project
*/
function extractOrgAndProject(
connection: WebApi,
projectId?: string,
): { organization: string; project: string } {
// Extract organization from the connection URL
const url = connection.serverUrl;
const match = url.match(/https?:\/\/dev\.azure\.com\/([^/]+)/);
const organization = match ? match[1] : '';
if (!organization) {
throw new AzureDevOpsValidationError(
'Could not extract organization from connection URL',
);
}
return {
organization,
project: projectId || '',
};
}
/**
* Get the authorization header from the connection
*
* @returns The authorization header
*/
async function getAuthorizationHeader(): Promise<string> {
try {
// For PAT authentication, we can construct the header directly
if (
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'pat' &&
process.env.AZURE_DEVOPS_PAT
) {
// For PAT auth, we can construct the Basic auth header directly
const token = process.env.AZURE_DEVOPS_PAT;
const base64Token = Buffer.from(`:${token}`).toString('base64');
return `Basic ${base64Token}`;
}
// For Azure Identity / Azure CLI auth, we need to get a token
// using the Azure DevOps resource ID
// Choose the appropriate credential based on auth method
const credential =
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-cli'
? new AzureCliCredential()
: new DefaultAzureCredential();
// Azure DevOps resource ID for token acquisition
const AZURE_DEVOPS_RESOURCE_ID = '499b84ac-1321-427f-aa17-267ca6975798';
// Get token for Azure DevOps
const token = await credential.getToken(
`${AZURE_DEVOPS_RESOURCE_ID}/.default`,
);
if (!token || !token.token) {
throw new Error('Failed to acquire token for Azure DevOps');
}
return `Bearer ${token.token}`;
} catch (error) {
throw new AzureDevOpsValidationError(
`Failed to get authorization header: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
```
--------------------------------------------------------------------------------
/src/features/pull-requests/list-pull-requests/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { PullRequest } from '../types';
import { listPullRequests } from './feature';
import { createPullRequest } from '../create-pull-request/feature';
import {
getTestConnection,
shouldSkipIntegrationTest,
} from '../../../shared/test/test-helpers';
describe('listPullRequests integration', () => {
let connection: WebApi | null = null;
let testPullRequest: PullRequest | null = null;
let projectName: string;
let repositoryName: string;
// Generate unique branch name and PR title using timestamp
const timestamp = Date.now();
const randomSuffix = Math.floor(Math.random() * 1000);
const uniqueBranchName = `test-branch-${timestamp}-${randomSuffix}`;
const uniqueTitle = `Test PR ${timestamp}-${randomSuffix}`;
beforeAll(async () => {
// Get a real connection using environment variables
connection = await getTestConnection();
// Set up project and repository names from environment
projectName = process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
repositoryName = process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || '';
// Skip setup if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
return;
}
});
afterAll(async () => {
// Clean up created resources if needed
if (
testPullRequest &&
testPullRequest.pullRequestId &&
!shouldSkipIntegrationTest()
) {
try {
// Abandon the test pull request if it was created
const gitApi = await connection?.getGitApi();
if (gitApi) {
await gitApi.updatePullRequest(
{
status: 2, // 2 = Abandoned
},
repositoryName,
testPullRequest.pullRequestId,
projectName,
);
}
} catch (error) {
console.error('Error cleaning up test pull request:', error);
}
}
});
test('should list pull requests from repository', async () => {
// Skip if integration tests should be skipped
if (shouldSkipIntegrationTest() || !connection) {
console.log('Skipping test due to missing connection');
return;
}
// Skip if repository name is not defined
if (!repositoryName) {
console.log('Skipping test due to missing repository name');
return;
}
try {
// Create a branch for testing
const gitApi = await connection.getGitApi();
// Get the default branch info
const repository = await gitApi.getRepository(
repositoryName,
projectName,
);
if (!repository || !repository.defaultBranch) {
throw new Error('Cannot find repository or default branch');
}
// Get the commit to branch from
const commits = await gitApi.getCommits(
repositoryName,
{
itemVersion: {
versionType: 0, // commit
version: repository.defaultBranch.replace('refs/heads/', ''),
},
$top: 1,
},
projectName,
);
if (!commits || commits.length === 0) {
throw new Error('Cannot find commits in repository');
}
// Create a new branch
const refUpdate = {
name: `refs/heads/${uniqueBranchName}`,
oldObjectId: '0000000000000000000000000000000000000000',
newObjectId: commits[0].commitId,
};
const updateResult = await gitApi.updateRefs(
[refUpdate],
repositoryName,
projectName,
);
if (
!updateResult ||
updateResult.length === 0 ||
!updateResult[0].success
) {
throw new Error('Failed to create new branch');
}
// Create a test pull request
testPullRequest = await createPullRequest(
connection,
projectName,
repositoryName,
{
title: uniqueTitle,
description: 'Test pull request for integration testing',
sourceRefName: `refs/heads/${uniqueBranchName}`,
targetRefName: repository.defaultBranch,
isDraft: true,
},
);
// List pull requests
const pullRequests = await listPullRequests(
connection,
projectName,
repositoryName,
{ projectId: projectName, repositoryId: repositoryName },
);
// Verify
expect(pullRequests).toBeDefined();
expect(pullRequests.value).toBeDefined();
expect(Array.isArray(pullRequests.value)).toBe(true);
expect(typeof pullRequests.count).toBe('number');
expect(typeof pullRequests.hasMoreResults).toBe('boolean');
// Find our test PR in the list
const foundPR = pullRequests.value.find(
(pr) => pr.pullRequestId === testPullRequest?.pullRequestId,
);
expect(foundPR).toBeDefined();
expect(foundPR?.title).toBe(uniqueTitle);
// Test with filters
const filteredPRs = await listPullRequests(
connection,
projectName,
repositoryName,
{
projectId: projectName,
repositoryId: repositoryName,
status: 'active',
top: 5,
},
);
expect(filteredPRs).toBeDefined();
expect(filteredPRs.value).toBeDefined();
expect(Array.isArray(filteredPRs.value)).toBe(true);
expect(filteredPRs.count).toBeGreaterThanOrEqual(0);
if (testPullRequest?.pullRequestId) {
const singlePR = await listPullRequests(
connection,
projectName,
repositoryName,
{
projectId: projectName,
repositoryId: repositoryName,
pullRequestId: testPullRequest.pullRequestId,
},
);
expect(singlePR.count).toBe(1);
expect(singlePR.value[0]?.pullRequestId).toBe(
testPullRequest.pullRequestId,
);
expect(singlePR.hasMoreResults).toBe(false);
}
} catch (error) {
console.error('Test error:', error);
throw error;
}
}, 30000); // 30 second timeout for integration test
});
```
--------------------------------------------------------------------------------
/src/features/repositories/get-file-content/feature.spec.unit.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { IGitApi } from 'azure-devops-node-api/GitApi';
import { GitVersionType } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { AzureDevOpsResourceNotFoundError } from '../../../shared/errors';
import { getFileContent } from './feature';
import { Readable } from 'stream';
describe('getFileContent', () => {
let mockConnection: WebApi;
let mockGitApi: IGitApi;
const mockRepositoryId = 'test-repo';
const mockProjectId = 'test-project';
const mockFilePath = '/path/to/file.txt';
const mockFileContent = 'Test file content';
const mockItem = {
objectId: '123456',
path: mockFilePath,
url: 'https://dev.azure.com/org/project/_apis/git/repositories/repo/items/path/to/file.txt',
gitObjectType: 'blob',
};
// Helper function to create a readable stream from a string
function createReadableStream(content: string): Readable {
const stream = new Readable();
stream.push(content);
stream.push(null); // Signals the end of the stream
return stream;
}
beforeEach(() => {
mockGitApi = {
getItemContent: jest
.fn()
.mockResolvedValue(createReadableStream(mockFileContent)),
getItems: jest.fn().mockResolvedValue([mockItem]),
} as unknown as IGitApi;
mockConnection = {
getGitApi: jest.fn().mockResolvedValue(mockGitApi),
} as unknown as WebApi;
});
it('should get file content for a file in the default branch', async () => {
const result = await getFileContent(
mockConnection,
mockProjectId,
mockRepositoryId,
mockFilePath,
);
expect(mockConnection.getGitApi).toHaveBeenCalled();
expect(mockGitApi.getItems).toHaveBeenCalledWith(
mockRepositoryId,
mockProjectId,
mockFilePath,
expect.any(Number), // VersionControlRecursionType.OneLevel
undefined,
undefined,
undefined,
undefined,
undefined,
);
expect(mockGitApi.getItemContent).toHaveBeenCalledWith(
mockRepositoryId,
mockFilePath,
mockProjectId,
undefined,
undefined,
undefined,
undefined,
false,
undefined,
true,
);
expect(result).toEqual({
content: mockFileContent,
isDirectory: false,
});
});
it('should get file content for a file in a specific branch', async () => {
const branchName = 'test-branch';
const versionDescriptor = {
versionType: GitVersionType.Branch,
version: branchName,
versionOptions: undefined,
};
const result = await getFileContent(
mockConnection,
mockProjectId,
mockRepositoryId,
mockFilePath,
{
versionType: GitVersionType.Branch,
version: branchName,
},
);
expect(mockConnection.getGitApi).toHaveBeenCalled();
expect(mockGitApi.getItems).toHaveBeenCalledWith(
mockRepositoryId,
mockProjectId,
mockFilePath,
expect.any(Number), // VersionControlRecursionType.OneLevel
undefined,
undefined,
undefined,
undefined,
versionDescriptor,
);
expect(mockGitApi.getItemContent).toHaveBeenCalledWith(
mockRepositoryId,
mockFilePath,
mockProjectId,
undefined,
undefined,
undefined,
undefined,
false,
versionDescriptor,
true,
);
expect(result).toEqual({
content: mockFileContent,
isDirectory: false,
});
});
it('should throw an error if the file is not found', async () => {
// Mock getItems to throw an error
mockGitApi.getItems = jest
.fn()
.mockRejectedValue(new Error('Item not found'));
// Mock getItemContent to throw a specific error indicating not found
mockGitApi.getItemContent = jest
.fn()
.mockRejectedValue(new Error('Item not found'));
await expect(
getFileContent(
mockConnection,
mockProjectId,
mockRepositoryId,
'/invalid/path',
),
).rejects.toThrow(AzureDevOpsResourceNotFoundError);
});
it('should get directory content if the path is a directory', async () => {
const dirPath = '/path/to/dir';
const mockDirectoryItems = [
{
path: `${dirPath}/file1.txt`,
gitObjectType: 'blob',
isFolder: false,
},
{
path: `${dirPath}/file2.md`,
gitObjectType: 'blob',
isFolder: false,
},
{
path: `${dirPath}/subdir`,
gitObjectType: 'tree',
isFolder: true,
},
];
// Mock getItems to return multiple items, indicating a directory
mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems);
const result = await getFileContent(
mockConnection,
mockProjectId,
mockRepositoryId,
dirPath,
);
expect(mockConnection.getGitApi).toHaveBeenCalled();
expect(mockGitApi.getItems).toHaveBeenCalledWith(
mockRepositoryId,
mockProjectId,
dirPath,
expect.any(Number), // VersionControlRecursionType.OneLevel
undefined,
undefined,
undefined,
undefined,
undefined,
);
// Should not attempt to get file content for a directory
expect(mockGitApi.getItemContent).not.toHaveBeenCalled();
expect(result).toEqual({
content: JSON.stringify(mockDirectoryItems, null, 2),
isDirectory: true,
});
});
it('should handle a directory path with trailing slash', async () => {
const dirPath = '/path/to/dir/';
const mockDirectoryItems = [
{
path: `${dirPath}file1.txt`,
gitObjectType: 'blob',
isFolder: false,
},
];
// Even with one item, it should be treated as a directory due to trailing slash
mockGitApi.getItems = jest.fn().mockResolvedValue(mockDirectoryItems);
const result = await getFileContent(
mockConnection,
mockProjectId,
mockRepositoryId,
dirPath,
);
expect(result.isDirectory).toBe(true);
expect(result.content).toBe(JSON.stringify(mockDirectoryItems, null, 2));
});
});
```
--------------------------------------------------------------------------------
/src/features/search/search-work-items/feature.spec.int.ts:
--------------------------------------------------------------------------------
```typescript
import { WebApi } from 'azure-devops-node-api';
import { searchWorkItems } from './feature';
import { getConnection } from '../../../server';
import { AzureDevOpsConfig } from '../../../shared/types';
import { AuthenticationMethod } from '../../../shared/auth';
// Skip tests if no PAT is available
const hasPat = process.env.AZURE_DEVOPS_PAT && process.env.AZURE_DEVOPS_ORG_URL;
const describeOrSkip = hasPat ? describe : describe.skip;
describeOrSkip('searchWorkItems (Integration)', () => {
let connection: WebApi;
let config: AzureDevOpsConfig;
let projectId: string;
beforeAll(async () => {
// Set up the connection
config = {
organizationUrl: process.env.AZURE_DEVOPS_ORG_URL || '',
authMethod: AuthenticationMethod.PersonalAccessToken,
personalAccessToken: process.env.AZURE_DEVOPS_PAT || '',
defaultProject: process.env.AZURE_DEVOPS_DEFAULT_PROJECT || '',
};
connection = await getConnection(config);
projectId = config.defaultProject || '';
// Skip tests if no default project is set
if (!projectId) {
console.warn('Skipping integration tests: No default project set');
}
}, 30000);
it('should search for work items', async () => {
// Skip test if no default project
if (!projectId) {
return;
}
// Act
const result = await searchWorkItems(connection, {
searchText: 'test',
projectId,
top: 10,
includeFacets: true,
});
// Assert
expect(result).toBeDefined();
expect(typeof result.count).toBe('number');
expect(Array.isArray(result.results)).toBe(true);
// If there are results, verify their structure
if (result.results.length > 0) {
const firstResult = result.results[0];
expect(firstResult.project).toBeDefined();
expect(firstResult.fields).toBeDefined();
expect(firstResult.fields['system.id']).toBeDefined();
expect(firstResult.fields['system.title']).toBeDefined();
expect(firstResult.hits).toBeDefined();
expect(firstResult.url).toBeDefined();
}
// If facets were requested, verify their structure
if (result.facets) {
expect(result.facets).toBeDefined();
}
}, 30000);
it('should filter work items by type', async () => {
// Skip test if no default project
if (!projectId) {
return;
}
// Act
const result = await searchWorkItems(connection, {
searchText: 'test',
projectId,
filters: {
'System.WorkItemType': ['Bug'],
},
top: 10,
});
// Assert
expect(result).toBeDefined();
// If there are results, verify they are all bugs
if (result.results.length > 0) {
result.results.forEach((item) => {
expect(item.fields['system.workitemtype'].toLowerCase()).toBe('bug');
});
}
}, 30000);
it('should support pagination', async () => {
// Skip test if no default project
if (!projectId) {
return;
}
// Act - Get first page
const firstPage = await searchWorkItems(connection, {
searchText: 'test',
projectId,
top: 5,
skip: 0,
});
// If there are enough results, test pagination
if (firstPage.count > 5) {
// Act - Get second page
const secondPage = await searchWorkItems(connection, {
searchText: 'test',
projectId,
top: 5,
skip: 5,
});
// Assert
expect(secondPage).toBeDefined();
expect(secondPage.results).toBeDefined();
// Verify the pages have different items
if (firstPage.results.length > 0 && secondPage.results.length > 0) {
const firstPageIds = firstPage.results.map(
(r) => r.fields['system.id'],
);
const secondPageIds = secondPage.results.map(
(r) => r.fields['system.id'],
);
// Check that the pages don't have overlapping IDs
const overlap = firstPageIds.filter((id) => secondPageIds.includes(id));
expect(overlap.length).toBe(0);
}
}
}, 30000);
it('should support sorting', async () => {
// Skip test if no default project
if (!projectId) {
return;
}
// Act - Get results sorted by creation date (newest first)
const result = await searchWorkItems(connection, {
searchText: 'test',
projectId,
orderBy: [{ field: 'System.CreatedDate', sortOrder: 'DESC' }],
top: 10,
});
// Assert
expect(result).toBeDefined();
// If there are multiple results, verify they are sorted
if (result.results.length > 1) {
const dates = result.results
.filter((r) => r.fields['system.createddate'] !== undefined)
.map((r) =>
new Date(r.fields['system.createddate'] as string).getTime(),
);
// Check that dates are in descending order
for (let i = 0; i < dates.length - 1; i++) {
expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]);
}
}
}, 30000);
// Add a test to verify Azure Identity authentication if configured
if (
process.env.AZURE_DEVOPS_AUTH_METHOD?.toLowerCase() === 'azure-identity'
) {
test('should search work items using Azure Identity authentication', async () => {
// Skip if required environment variables are missing
if (!process.env.AZURE_DEVOPS_ORG_URL || !process.env.TEST_PROJECT_ID) {
console.log('Skipping test: required environment variables missing');
return;
}
// Create a config with Azure Identity authentication
const testConfig: AzureDevOpsConfig = {
organizationUrl: process.env.AZURE_DEVOPS_ORG_URL,
authMethod: AuthenticationMethod.AzureIdentity,
defaultProject: process.env.TEST_PROJECT_ID,
};
// Create the connection using the config
const connection = await getConnection(testConfig);
// Search work items
const result = await searchWorkItems(connection, {
projectId: process.env.TEST_PROJECT_ID,
searchText: 'test',
});
// Check that the response is properly formatted
expect(result).toBeDefined();
expect(result.count).toBeDefined();
expect(Array.isArray(result.results)).toBe(true);
});
}
});
```
--------------------------------------------------------------------------------
/docs/tools/work-items.md:
--------------------------------------------------------------------------------
```markdown
# Work Item Tools
This document describes the tools available for working with Azure DevOps work items.
## Table of Contents
- [`get_work_item`](#get_work_item) - Retrieve a specific work item by ID
- [`create_work_item`](#create_work_item) - Create a new work item
- [`list_work_items`](#list_work_items) - List work items in a project
## get_work_item
Retrieves a work item by its ID.
### Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | --------------------------------------------------------------------------------- |
| `workItemId` | number | Yes | The ID of the work item to retrieve |
| `expand` | string | No | Controls the level of detail in the response. Defaults to "All" if not specified. Other values: "Relations", "Fields", "None" |
### Response
Returns a work item object with the following structure:
```json
{
"id": 123,
"fields": {
"System.Title": "Sample Work Item",
"System.State": "Active",
"System.AssignedTo": "[email protected]",
"System.Description": "Description of the work item"
},
"url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123"
}
```
### Error Handling
- Returns `AzureDevOpsResourceNotFoundError` if the work item does not exist
- Returns `AzureDevOpsAuthenticationError` if authentication fails
- Returns generic error messages for other failures
### Example Usage
```javascript
// Using default expand="All"
const result = await callTool('get_work_item', {
workItemId: 123,
});
// Explicitly specifying expand
const minimalResult = await callTool('get_work_item', {
workItemId: 123,
expand: 'None'
});
```
## create_work_item
Creates a new work item in a specified project.
### Parameters
| Parameter | Type | Required | Description |
| ------------------ | ------ | -------- | ------------------------------------------------------------------- |
| `projectId` | string | Yes | The ID or name of the project where the work item will be created |
| `workItemType` | string | Yes | The type of work item to create (e.g., "Task", "Bug", "User Story") |
| `title` | string | Yes | The title of the work item |
| `description` | string | No | The description of the work item |
| `assignedTo` | string | No | The email or name of the user to assign the work item to |
| `areaPath` | string | No | The area path for the work item |
| `iterationPath` | string | No | The iteration path for the work item |
| `priority` | number | No | The priority of the work item |
| `additionalFields` | object | No | Additional fields to set on the work item (key-value pairs) |
### Response
Returns the newly created work item object:
```json
{
"id": 124,
"fields": {
"System.Title": "New Work Item",
"System.State": "New",
"System.Description": "Description of the new work item",
"System.AssignedTo": "[email protected]",
"System.AreaPath": "Project\\Team",
"System.IterationPath": "Project\\Sprint 1",
"Microsoft.VSTS.Common.Priority": 2
},
"url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124"
}
```
### Error Handling
- Returns validation error if required fields are missing
- Returns `AzureDevOpsAuthenticationError` if authentication fails
- Returns `AzureDevOpsResourceNotFoundError` if the project does not exist
- Returns generic error messages for other failures
### Example Usage
```javascript
const result = await callTool('create_work_item', {
projectId: 'my-project',
workItemType: 'User Story',
title: 'Implement login functionality',
description:
'Create a secure login system with email and password authentication',
assignedTo: '[email protected]',
priority: 1,
additionalFields: {
'Custom.Field': 'Custom Value',
},
});
```
### Implementation Details
The tool creates a JSON patch document to define the fields of the work item, then calls the Azure DevOps API to create the work item. Each field is added to the document with an 'add' operation, and the document is submitted to the API.
## list_work_items
Lists work items in a specified project.
### Parameters
| Parameter | Type | Required | Description |
| ----------- | ------ | -------- | ----------------------------------------------------- |
| `projectId` | string | Yes | The ID or name of the project to list work items from |
| `teamId` | string | No | The ID of the team to list work items for |
| `queryId` | string | No | ID of a saved work item query |
| `wiql` | string | No | Work Item Query Language (WIQL) query |
| `top` | number | No | Maximum number of work items to return |
| `skip` | number | No | Number of work items to skip |
### Response
Returns an array of work item objects:
```json
[
{
"id": 123,
"fields": {
"System.Title": "Sample Work Item",
"System.State": "Active",
"System.AssignedTo": "[email protected]"
},
"url": "https://dev.azure.com/organization/project/_apis/wit/workItems/123"
},
{
"id": 124,
"fields": {
"System.Title": "Another Work Item",
"System.State": "New",
"System.AssignedTo": "[email protected]"
},
"url": "https://dev.azure.com/organization/project/_apis/wit/workItems/124"
}
]
```
### Error Handling
- Returns `AzureDevOpsResourceNotFoundError` if the project does not exist
- Returns `AzureDevOpsAuthenticationError` if authentication fails
- Returns generic error messages for other failures
### Example Usage
```javascript
const result = await callTool('list_work_items', {
projectId: 'my-project',
wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.WorkItemType] = 'Task' ORDER BY [System.CreatedDate] DESC",
top: 10,
});
```
```