Skip to content

Commit ab9c255

Browse files
mamankhan99Tiberriver256
authored andcommitted
feat: create pull request
1 parent 1ae190a commit ab9c255

File tree

9 files changed

+392
-0
lines changed

9 files changed

+392
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { WebApi } from 'azure-devops-node-api';
2+
import { createPullRequest } from './feature';
3+
import {
4+
getTestConnection,
5+
shouldSkipIntegrationTest,
6+
} from '@/shared/test/test-helpers';
7+
import { GitRefUpdate } from 'azure-devops-node-api/interfaces/GitInterfaces';
8+
9+
describe('createPullRequest integration', () => {
10+
let connection: WebApi | null = null;
11+
12+
beforeAll(async () => {
13+
// Get a real connection using environment variables
14+
connection = await getTestConnection();
15+
});
16+
17+
test('should create a new pull request in Azure DevOps', async () => {
18+
// Skip if no connection is available
19+
if (shouldSkipIntegrationTest()) {
20+
return;
21+
}
22+
23+
// This connection must be available if we didn't skip
24+
if (!connection) {
25+
throw new Error(
26+
'Connection should be available when test is not skipped',
27+
);
28+
}
29+
30+
// Create a unique title using timestamp to avoid conflicts
31+
const uniqueTitle = `Test Pull Request ${new Date().toISOString()}`;
32+
33+
// For a true integration test, use a real project and repository
34+
const projectName =
35+
process.env.AZURE_DEVOPS_DEFAULT_PROJECT || 'DefaultProject';
36+
const repositoryId =
37+
process.env.AZURE_DEVOPS_DEFAULT_REPOSITORY || 'DefaultRepo';
38+
39+
// Create a unique branch name
40+
const uniqueBranchName = `test-branch-${new Date().getTime()}`;
41+
42+
// Get the Git API
43+
const gitApi = await connection.getGitApi();
44+
45+
// Get the main branch's object ID
46+
const refs = await gitApi.getRefs(repositoryId, projectName, 'heads/main');
47+
if (!refs || refs.length === 0) {
48+
throw new Error('Could not find main branch');
49+
}
50+
51+
const mainBranchObjectId = refs[0].objectId;
52+
53+
// Create a new branch from main
54+
const refUpdate: GitRefUpdate = {
55+
name: `refs/heads/${uniqueBranchName}`,
56+
oldObjectId: '0000000000000000000000000000000000000000', // Required for new branch creation
57+
newObjectId: mainBranchObjectId,
58+
};
59+
60+
const updateResult = await gitApi.updateRefs(
61+
[refUpdate],
62+
repositoryId,
63+
projectName,
64+
);
65+
66+
if (
67+
!updateResult ||
68+
updateResult.length === 0 ||
69+
!updateResult[0].success
70+
) {
71+
throw new Error('Failed to create new branch');
72+
}
73+
74+
// Create a pull request with the new branch
75+
const result = await createPullRequest(
76+
connection,
77+
projectName,
78+
repositoryId,
79+
{
80+
title: uniqueTitle,
81+
description:
82+
'This is a test pull request created by an integration test',
83+
sourceRefName: `refs/heads/${uniqueBranchName}`,
84+
targetRefName: 'refs/heads/main',
85+
isDraft: true,
86+
},
87+
);
88+
89+
// Assert on the actual response
90+
expect(result).toBeDefined();
91+
expect(result.pullRequestId).toBeDefined();
92+
expect(result.title).toBe(uniqueTitle);
93+
expect(result.description).toBe(
94+
'This is a test pull request created by an integration test',
95+
);
96+
expect(result.sourceRefName).toBe(`refs/heads/${uniqueBranchName}`);
97+
expect(result.targetRefName).toBe('refs/heads/main');
98+
expect(result.isDraft).toBe(true);
99+
});
100+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { createPullRequest } from './feature';
2+
import { AzureDevOpsError } from '../../../shared/errors';
3+
4+
describe('createPullRequest unit', () => {
5+
// Test for required fields validation
6+
test('should throw error when title is not provided', async () => {
7+
// Arrange - mock connection, never used due to validation error
8+
const mockConnection: any = {
9+
getGitApi: jest.fn(),
10+
};
11+
12+
// Act & Assert
13+
await expect(
14+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
15+
title: '',
16+
sourceRefName: 'refs/heads/feature-branch',
17+
targetRefName: 'refs/heads/main',
18+
}),
19+
).rejects.toThrow('Title is required');
20+
});
21+
22+
test('should throw error when source branch is not provided', async () => {
23+
// Arrange - mock connection, never used due to validation error
24+
const mockConnection: any = {
25+
getGitApi: jest.fn(),
26+
};
27+
28+
// Act & Assert
29+
await expect(
30+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
31+
title: 'Test PR',
32+
sourceRefName: '',
33+
targetRefName: 'refs/heads/main',
34+
}),
35+
).rejects.toThrow('Source branch is required');
36+
});
37+
38+
test('should throw error when target branch is not provided', async () => {
39+
// Arrange - mock connection, never used due to validation error
40+
const mockConnection: any = {
41+
getGitApi: jest.fn(),
42+
};
43+
44+
// Act & Assert
45+
await expect(
46+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
47+
title: 'Test PR',
48+
sourceRefName: 'refs/heads/feature-branch',
49+
targetRefName: '',
50+
}),
51+
).rejects.toThrow('Target branch is required');
52+
});
53+
54+
// Test for error propagation
55+
test('should propagate custom errors when thrown internally', async () => {
56+
// Arrange
57+
const mockConnection: any = {
58+
getGitApi: jest.fn().mockImplementation(() => {
59+
throw new AzureDevOpsError('Custom error');
60+
}),
61+
};
62+
63+
// Act & Assert
64+
await expect(
65+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
66+
title: 'Test PR',
67+
sourceRefName: 'refs/heads/feature-branch',
68+
targetRefName: 'refs/heads/main',
69+
}),
70+
).rejects.toThrow(AzureDevOpsError);
71+
72+
await expect(
73+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
74+
title: 'Test PR',
75+
sourceRefName: 'refs/heads/feature-branch',
76+
targetRefName: 'refs/heads/main',
77+
}),
78+
).rejects.toThrow('Custom error');
79+
});
80+
81+
test('should wrap unexpected errors in a friendly error message', async () => {
82+
// Arrange
83+
const mockConnection: any = {
84+
getGitApi: jest.fn().mockImplementation(() => {
85+
throw new Error('Unexpected error');
86+
}),
87+
};
88+
89+
// Act & Assert
90+
await expect(
91+
createPullRequest(mockConnection, 'TestProject', 'TestRepo', {
92+
title: 'Test PR',
93+
sourceRefName: 'refs/heads/feature-branch',
94+
targetRefName: 'refs/heads/main',
95+
}),
96+
).rejects.toThrow('Failed to create pull request: Unexpected error');
97+
});
98+
});
+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { WebApi } from 'azure-devops-node-api';
2+
import { AzureDevOpsError } from '../../../shared/errors';
3+
import { CreatePullRequestOptions, PullRequest } from '../types';
4+
5+
/**
6+
* Create a pull request
7+
*
8+
* @param connection The Azure DevOps WebApi connection
9+
* @param projectId The ID or name of the project
10+
* @param repositoryId The ID or name of the repository
11+
* @param options Options for creating the pull request
12+
* @returns The created pull request
13+
*/
14+
export async function createPullRequest(
15+
connection: WebApi,
16+
projectId: string,
17+
repositoryId: string,
18+
options: CreatePullRequestOptions,
19+
): Promise<PullRequest> {
20+
try {
21+
if (!options.title) {
22+
throw new Error('Title is required');
23+
}
24+
25+
if (!options.sourceRefName) {
26+
throw new Error('Source branch is required');
27+
}
28+
29+
if (!options.targetRefName) {
30+
throw new Error('Target branch is required');
31+
}
32+
33+
const gitApi = await connection.getGitApi();
34+
35+
// Create the pull request object
36+
const pullRequest: PullRequest = {
37+
title: options.title,
38+
description: options.description,
39+
sourceRefName: options.sourceRefName,
40+
targetRefName: options.targetRefName,
41+
isDraft: options.isDraft || false,
42+
workItemRefs: options.workItemRefs?.map((id) => ({
43+
id: id.toString(),
44+
})),
45+
reviewers: options.reviewers?.map((reviewer) => ({
46+
id: reviewer,
47+
isRequired: true,
48+
})),
49+
...options.additionalProperties,
50+
};
51+
52+
// Create the pull request
53+
const createdPullRequest = await gitApi.createPullRequest(
54+
pullRequest,
55+
repositoryId,
56+
projectId,
57+
);
58+
59+
if (!createdPullRequest) {
60+
throw new Error('Failed to create pull request');
61+
}
62+
63+
return createdPullRequest;
64+
} catch (error) {
65+
if (error instanceof AzureDevOpsError) {
66+
throw error;
67+
}
68+
throw new Error(
69+
`Failed to create pull request: ${error instanceof Error ? error.message : String(error)}`,
70+
);
71+
}
72+
}
+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './schema';
2+
export * from './feature';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { CreatePullRequestSchema } from '../schemas';
2+
3+
export { CreatePullRequestSchema };

Diff for: src/features/pull-requests/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './schemas';
2+
export * from './types';
3+
export * from './create-pull-request';

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

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { z } from 'zod';
2+
import { defaultProject, defaultOrg } from '../../utils/environment';
3+
4+
/**
5+
* Schema for creating a pull request
6+
*/
7+
export const CreatePullRequestSchema = z.object({
8+
projectId: z
9+
.string()
10+
.optional()
11+
.describe(`The ID or name of the project (Default: ${defaultProject})`),
12+
organizationId: z
13+
.string()
14+
.optional()
15+
.describe(`The ID or name of the organization (Default: ${defaultOrg})`),
16+
repositoryId: z.string().describe('The ID or name of the repository'),
17+
title: z.string().describe('The title of the pull request'),
18+
description: z
19+
.string()
20+
.optional()
21+
.describe('The description of the pull request'),
22+
sourceRefName: z
23+
.string()
24+
.describe('The source branch name (e.g., refs/heads/feature-branch)'),
25+
targetRefName: z
26+
.string()
27+
.describe('The target branch name (e.g., refs/heads/main)'),
28+
reviewers: z
29+
.array(z.string())
30+
.optional()
31+
.describe('List of reviewer email addresses or IDs'),
32+
isDraft: z
33+
.boolean()
34+
.optional()
35+
.describe('Whether the pull request should be created as a draft'),
36+
workItemRefs: z
37+
.array(z.number())
38+
.optional()
39+
.describe('List of work item IDs to link to the pull request'),
40+
additionalProperties: z
41+
.record(z.string(), z.any())
42+
.optional()
43+
.describe('Additional properties to set on the pull request'),
44+
});

Diff for: src/features/pull-requests/types.ts

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { GitPullRequest } from 'azure-devops-node-api/interfaces/GitInterfaces';
2+
3+
export type PullRequest = GitPullRequest;
4+
5+
/**
6+
* Options for creating a pull request
7+
*/
8+
export interface CreatePullRequestOptions {
9+
title: string;
10+
description?: string;
11+
sourceRefName: string;
12+
targetRefName: string;
13+
reviewers?: string[];
14+
isDraft?: boolean;
15+
workItemRefs?: number[];
16+
additionalProperties?: Record<string, any>;
17+
}
18+
19+
/**
20+
* Options for listing pull requests
21+
*/
22+
export interface ListPullRequestsOptions {
23+
projectId: string;
24+
repositoryId: string;
25+
status?: 'all' | 'active' | 'completed' | 'abandoned';
26+
creatorId?: string;
27+
reviewerId?: string;
28+
sourceRefName?: string;
29+
targetRefName?: string;
30+
top?: number;
31+
skip?: number;
32+
}
33+
34+
/**
35+
* Options for updating a pull request
36+
*/
37+
export interface UpdatePullRequestOptions {
38+
title?: string;
39+
description?: string;
40+
status?: 'active' | 'completed' | 'abandoned';
41+
reviewers?: string[];
42+
isDraft?: boolean;
43+
additionalProperties?: Record<string, any>;
44+
}

0 commit comments

Comments
 (0)