Skip to content

Commit a95e6f2

Browse files
hmaldonadovillaTiberriver256
authored andcommitted
feat: add repository tree and branch/commit tools
1 parent fb109a7 commit a95e6f2

File tree

21 files changed

+887
-0
lines changed

21 files changed

+887
-0
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getPullRequestChanges } from './feature';
2+
import { AzureDevOpsError } from '../../../shared/errors';
3+
4+
describe('getPullRequestChanges unit', () => {
5+
test('should retrieve changes and evaluations', async () => {
6+
const mockConnection: any = {
7+
getGitApi: jest.fn().mockResolvedValue({
8+
getPullRequestIterations: jest.fn().mockResolvedValue([{ id: 1 }]),
9+
getPullRequestIterationChanges: jest
10+
.fn()
11+
.mockResolvedValue({ changeEntries: [] }),
12+
}),
13+
getPolicyApi: jest.fn().mockResolvedValue({
14+
getPolicyEvaluations: jest.fn().mockResolvedValue([{ id: '1' }]),
15+
}),
16+
};
17+
18+
const result = await getPullRequestChanges(mockConnection, {
19+
projectId: 'p',
20+
repositoryId: 'r',
21+
pullRequestId: 1,
22+
});
23+
24+
expect(result.changes).toEqual({ changeEntries: [] });
25+
expect(result.evaluations).toHaveLength(1);
26+
});
27+
28+
test('should throw when no iterations found', async () => {
29+
const mockConnection: any = {
30+
getGitApi: jest.fn().mockResolvedValue({
31+
getPullRequestIterations: jest.fn().mockResolvedValue([]),
32+
}),
33+
};
34+
35+
await expect(
36+
getPullRequestChanges(mockConnection, {
37+
projectId: 'p',
38+
repositoryId: 'r',
39+
pullRequestId: 1,
40+
}),
41+
).rejects.toThrow(AzureDevOpsError);
42+
});
43+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { WebApi } from 'azure-devops-node-api';
2+
import { GitPullRequestIterationChanges } from 'azure-devops-node-api/interfaces/GitInterfaces';
3+
import { PolicyEvaluationRecord } from 'azure-devops-node-api/interfaces/PolicyInterfaces';
4+
import { AzureDevOpsError } from '../../../shared/errors';
5+
6+
export interface PullRequestChangesOptions {
7+
projectId: string;
8+
repositoryId: string;
9+
pullRequestId: number;
10+
}
11+
12+
export interface PullRequestChangesResponse {
13+
changes: GitPullRequestIterationChanges;
14+
evaluations: PolicyEvaluationRecord[];
15+
}
16+
17+
/**
18+
* Retrieve changes and policy evaluation status for a pull request
19+
*/
20+
export async function getPullRequestChanges(
21+
connection: WebApi,
22+
options: PullRequestChangesOptions,
23+
): Promise<PullRequestChangesResponse> {
24+
try {
25+
const gitApi = await connection.getGitApi();
26+
const iterations = await gitApi.getPullRequestIterations(
27+
options.repositoryId,
28+
options.pullRequestId,
29+
options.projectId,
30+
);
31+
if (!iterations || iterations.length === 0) {
32+
throw new AzureDevOpsError('No iterations found for pull request');
33+
}
34+
const latest = iterations[iterations.length - 1];
35+
const changes = await gitApi.getPullRequestIterationChanges(
36+
options.repositoryId,
37+
options.pullRequestId,
38+
latest.id!,
39+
options.projectId,
40+
);
41+
42+
const policyApi = await connection.getPolicyApi();
43+
const artifactId = `vstfs:///CodeReview/CodeReviewId/${options.projectId}/${options.pullRequestId}`;
44+
const evaluations = await policyApi.getPolicyEvaluations(
45+
options.projectId,
46+
artifactId,
47+
);
48+
49+
return { changes, evaluations };
50+
} catch (error) {
51+
if (error instanceof AzureDevOpsError) {
52+
throw error;
53+
}
54+
throw new Error(
55+
`Failed to get pull request changes: ${error instanceof Error ? error.message : String(error)}`,
56+
);
57+
}
58+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './feature';

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { listPullRequests } from './list-pull-requests';
66
import { getPullRequestComments } from './get-pull-request-comments';
77
import { addPullRequestComment } from './add-pull-request-comment';
88
import { AddPullRequestCommentSchema } from './schemas';
9+
import { getPullRequestChanges } from './get-pull-request-changes';
910

1011
// Mock the imported modules
1112
jest.mock('./create-pull-request', () => ({
@@ -24,6 +25,10 @@ jest.mock('./add-pull-request-comment', () => ({
2425
addPullRequestComment: jest.fn(),
2526
}));
2627

28+
jest.mock('./get-pull-request-changes', () => ({
29+
getPullRequestChanges: jest.fn(),
30+
}));
31+
2732
describe('Pull Requests Request Handlers', () => {
2833
const mockConnection = {} as WebApi;
2934

@@ -34,6 +39,7 @@ describe('Pull Requests Request Handlers', () => {
3439
'list_pull_requests',
3540
'get_pull_request_comments',
3641
'add_pull_request_comment',
42+
'get_pull_request_changes',
3743
];
3844
validTools.forEach((tool) => {
3945
const request = {
@@ -216,6 +222,25 @@ describe('Pull Requests Request Handlers', () => {
216222
AddPullRequestCommentSchema.parse = originalParse;
217223
});
218224

225+
it('should handle get_pull_request_changes request', async () => {
226+
const mockResult = { changes: { changeEntries: [] }, evaluations: [] };
227+
(getPullRequestChanges as jest.Mock).mockResolvedValue(mockResult);
228+
229+
const request = {
230+
params: {
231+
name: 'get_pull_request_changes',
232+
arguments: { repositoryId: 'test-repo', pullRequestId: 1 },
233+
},
234+
method: 'tools/call',
235+
} as CallToolRequest;
236+
237+
const response = await handlePullRequestsRequest(mockConnection, request);
238+
expect(JSON.parse(response.content[0].text as string)).toEqual(
239+
mockResult,
240+
);
241+
expect(getPullRequestChanges).toHaveBeenCalled();
242+
});
243+
219244
it('should throw error for unknown tool', async () => {
220245
const request = {
221246
params: {

src/features/pull-requests/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * from './list-pull-requests';
55
export * from './get-pull-request-comments';
66
export * from './add-pull-request-comment';
77
export * from './update-pull-request';
8+
export * from './get-pull-request-changes';
89

910
// Export tool definitions
1011
export * from './tool-definitions';
@@ -23,11 +24,13 @@ import {
2324
GetPullRequestCommentsSchema,
2425
AddPullRequestCommentSchema,
2526
UpdatePullRequestSchema,
27+
GetPullRequestChangesSchema,
2628
createPullRequest,
2729
listPullRequests,
2830
getPullRequestComments,
2931
addPullRequestComment,
3032
updatePullRequest,
33+
getPullRequestChanges,
3134
} from './';
3235

3336
/**
@@ -43,6 +46,7 @@ export const isPullRequestsRequest: RequestIdentifier = (
4346
'get_pull_request_comments',
4447
'add_pull_request_comment',
4548
'update_pull_request',
49+
'get_pull_request_changes',
4650
].includes(toolName);
4751
};
4852

@@ -146,6 +150,19 @@ export const handlePullRequestsRequest: RequestHandler = async (
146150
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
147151
};
148152
}
153+
case 'get_pull_request_changes': {
154+
const params = GetPullRequestChangesSchema.parse(
155+
request.params.arguments,
156+
);
157+
const result = await getPullRequestChanges(connection, {
158+
projectId: params.projectId ?? defaultProject,
159+
repositoryId: params.repositoryId,
160+
pullRequestId: params.pullRequestId,
161+
});
162+
return {
163+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
164+
};
165+
}
149166
default:
150167
throw new Error(`Unknown pull requests tool: ${request.params.name}`);
151168
}

src/features/pull-requests/schemas.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,19 @@ export const UpdatePullRequestSchema = z.object({
219219
.optional()
220220
.describe('Additional properties to update on the pull request'),
221221
});
222+
223+
/**
224+
* Schema for getting pull request changes and policy evaluations
225+
*/
226+
export const GetPullRequestChangesSchema = z.object({
227+
projectId: z
228+
.string()
229+
.optional()
230+
.describe(`The ID or name of the project (Default: ${defaultProject})`),
231+
organizationId: z
232+
.string()
233+
.optional()
234+
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
235+
repositoryId: z.string().describe('The ID or name of the repository'),
236+
pullRequestId: z.number().describe('The ID of the pull request'),
237+
});

src/features/pull-requests/tool-definitions.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
GetPullRequestCommentsSchema,
77
AddPullRequestCommentSchema,
88
UpdatePullRequestSchema,
9+
GetPullRequestChangesSchema,
910
} from './schemas';
1011

1112
/**
@@ -39,4 +40,10 @@ export const pullRequestsTools: ToolDefinition[] = [
3940
'Update an existing pull request with new properties, link work items, and manage reviewers',
4041
inputSchema: zodToJsonSchema(UpdatePullRequestSchema),
4142
},
43+
{
44+
name: 'get_pull_request_changes',
45+
description:
46+
'Get the files changed in a pull request and the status of policy evaluations',
47+
inputSchema: zodToJsonSchema(GetPullRequestChangesSchema),
48+
},
4249
];
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { createBranch } from './feature';
2+
import { AzureDevOpsError } from '../../../shared/errors';
3+
4+
describe('createBranch unit', () => {
5+
test('should create branch when source exists', async () => {
6+
const updateRefs = jest.fn().mockResolvedValue([{ success: true }]);
7+
const mockConnection: any = {
8+
getGitApi: jest.fn().mockResolvedValue({
9+
getBranch: jest.fn().mockResolvedValue({ commit: { commitId: 'abc' } }),
10+
updateRefs,
11+
}),
12+
};
13+
14+
await createBranch(mockConnection, {
15+
projectId: 'p',
16+
repositoryId: 'r',
17+
sourceBranch: 'main',
18+
newBranch: 'feature',
19+
});
20+
21+
expect(updateRefs).toHaveBeenCalled();
22+
});
23+
24+
test('should throw error when source branch missing', async () => {
25+
const mockConnection: any = {
26+
getGitApi: jest.fn().mockResolvedValue({
27+
getBranch: jest.fn().mockResolvedValue(null),
28+
}),
29+
};
30+
31+
await expect(
32+
createBranch(mockConnection, {
33+
projectId: 'p',
34+
repositoryId: 'r',
35+
sourceBranch: 'missing',
36+
newBranch: 'feature',
37+
}),
38+
).rejects.toThrow(AzureDevOpsError);
39+
});
40+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { WebApi } from 'azure-devops-node-api';
2+
import { GitRefUpdate } from 'azure-devops-node-api/interfaces/GitInterfaces';
3+
import { AzureDevOpsError } from '../../../shared/errors';
4+
import { CreateBranchOptions } from '../types';
5+
6+
/**
7+
* Create a new branch from an existing one
8+
*/
9+
export async function createBranch(
10+
connection: WebApi,
11+
options: CreateBranchOptions,
12+
): Promise<void> {
13+
try {
14+
const gitApi = await connection.getGitApi();
15+
const source = await gitApi.getBranch(
16+
options.repositoryId,
17+
options.sourceBranch,
18+
options.projectId,
19+
);
20+
const commitId = source?.commit?.commitId;
21+
if (!commitId) {
22+
throw new AzureDevOpsError(
23+
`Source branch '${options.sourceBranch}' not found`,
24+
);
25+
}
26+
27+
const refUpdate: GitRefUpdate = {
28+
name: `refs/heads/${options.newBranch}`,
29+
oldObjectId: '0000000000000000000000000000000000000000',
30+
newObjectId: commitId,
31+
};
32+
33+
const result = await gitApi.updateRefs(
34+
[refUpdate],
35+
options.repositoryId,
36+
options.projectId,
37+
);
38+
if (!result.every((r) => r.success)) {
39+
throw new AzureDevOpsError('Failed to create new branch');
40+
}
41+
} catch (error) {
42+
if (error instanceof AzureDevOpsError) {
43+
throw error;
44+
}
45+
throw new Error(
46+
`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`,
47+
);
48+
}
49+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './feature';

0 commit comments

Comments
 (0)