feat: enhance pull request operations with validation and error handling

- Add branch existence validation
- Add duplicate PR check
- Add comprehensive error handling
- Improve type safety with zod transforms
- Add input sanitization
This commit is contained in:
Peter M. Elias 2024-12-28 03:26:55 -08:00
commit 10bd24dd02

View file

@ -1,16 +1,28 @@
import { z } from "zod"; import { z } from "zod";
import { githubRequest } from "../common/utils.js"; import {
githubRequest,
validateBranchName,
validateOwnerName,
validateRepositoryName,
checkBranchExists,
} from "../common/utils.js";
import { import {
GitHubIssueAssigneeSchema, GitHubIssueAssigneeSchema,
GitHubRepositorySchema GitHubRepositorySchema
} from "../common/types.js"; } from "../common/types.js";
import {
GitHubError,
GitHubValidationError,
GitHubResourceNotFoundError,
GitHubConflictError,
} from "../common/errors.js";
const GITHUB_TITLE_MAX_LENGTH = 256; const GITHUB_TITLE_MAX_LENGTH = 256;
const GITHUB_BODY_MAX_LENGTH = 65536; const GITHUB_BODY_MAX_LENGTH = 65536;
export const RepositoryParamsSchema = z.object({ export const RepositoryParamsSchema = z.object({
owner: z.string().min(1), owner: z.string().min(1).transform(validateOwnerName),
repo: z.string().min(1), repo: z.string().min(1).transform(validateRepositoryName),
}); });
export const GitHubPullRequestStateSchema = z.enum([ export const GitHubPullRequestStateSchema = z.enum([
@ -34,7 +46,7 @@ export const GitHubDirectionSchema = z.enum([
export const GitHubPullRequestRefSchema = z.object({ export const GitHubPullRequestRefSchema = z.object({
label: z.string(), label: z.string(),
ref: z.string().min(1), ref: z.string().min(1).transform(validateBranchName),
sha: z.string().length(40), sha: z.string().length(40),
user: GitHubIssueAssigneeSchema, user: GitHubIssueAssigneeSchema,
repo: GitHubRepositorySchema, repo: GitHubRepositorySchema,
@ -73,8 +85,8 @@ export const GitHubPullRequestSchema = z.object({
export const ListPullRequestsOptionsSchema = z.object({ export const ListPullRequestsOptionsSchema = z.object({
state: GitHubPullRequestStateSchema.optional(), state: GitHubPullRequestStateSchema.optional(),
head: z.string().optional(), head: z.string().transform(validateBranchName).optional(),
base: z.string().optional(), base: z.string().transform(validateBranchName).optional(),
sort: GitHubPullRequestSortSchema.optional(), sort: GitHubPullRequestSortSchema.optional(),
direction: GitHubDirectionSchema.optional(), direction: GitHubDirectionSchema.optional(),
per_page: z.number().min(1).max(100).optional(), per_page: z.number().min(1).max(100).optional(),
@ -84,8 +96,8 @@ export const ListPullRequestsOptionsSchema = z.object({
export const CreatePullRequestOptionsSchema = z.object({ export const CreatePullRequestOptionsSchema = z.object({
title: z.string().max(GITHUB_TITLE_MAX_LENGTH), title: z.string().max(GITHUB_TITLE_MAX_LENGTH),
body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional(), body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional(),
head: z.string().min(1), head: z.string().min(1).transform(validateBranchName),
base: z.string().min(1), base: z.string().min(1).transform(validateBranchName),
maintainer_can_modify: z.boolean().optional(), maintainer_can_modify: z.boolean().optional(),
draft: z.boolean().optional(), draft: z.boolean().optional(),
}); });
@ -100,20 +112,86 @@ export type ListPullRequestsOptions = z.infer<typeof ListPullRequestsOptionsSche
export type GitHubPullRequest = z.infer<typeof GitHubPullRequestSchema>; export type GitHubPullRequest = z.infer<typeof GitHubPullRequestSchema>;
export type GitHubPullRequestRef = z.infer<typeof GitHubPullRequestRefSchema>; export type GitHubPullRequestRef = z.infer<typeof GitHubPullRequestRefSchema>;
async function validatePullRequestBranches(
owner: string,
repo: string,
head: string,
base: string
): Promise<void> {
const [headExists, baseExists] = await Promise.all([
checkBranchExists(owner, repo, head),
checkBranchExists(owner, repo, base),
]);
if (!headExists) {
throw new GitHubResourceNotFoundError(`Branch '${head}' not found`);
}
if (!baseExists) {
throw new GitHubResourceNotFoundError(`Branch '${base}' not found`);
}
if (head === base) {
throw new GitHubValidationError(
"Head and base branches cannot be the same",
422,
{ message: "Head and base branches must be different" }
);
}
}
async function checkForExistingPullRequest(
owner: string,
repo: string,
head: string,
base: string
): Promise<void> {
const existingPRs = await listPullRequests({
owner,
repo,
head,
base,
state: "open",
});
if (existingPRs.length > 0) {
throw new GitHubConflictError(
`A pull request already exists for ${head} into ${base}`
);
}
}
export async function createPullRequest( export async function createPullRequest(
params: z.infer<typeof CreatePullRequestSchema> params: z.infer<typeof CreatePullRequestSchema>
): Promise<GitHubPullRequest> { ): Promise<GitHubPullRequest> {
const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); const { owner, repo, ...options } = CreatePullRequestSchema.parse(params);
const response = await githubRequest( try {
`https://api.github.com/repos/${owner}/${repo}/pulls`, await validatePullRequestBranches(owner, repo, options.head, options.base);
{ await checkForExistingPullRequest(owner, repo, options.head, options.base);
method: "POST",
body: options,
}
);
return GitHubPullRequestSchema.parse(response); const response = await githubRequest(
`https://api.github.com/repos/${owner}/${repo}/pulls`,
{
method: "POST",
body: options,
}
);
return GitHubPullRequestSchema.parse(response);
} catch (error) {
if (error instanceof GitHubError) {
throw error;
}
if (error instanceof z.ZodError) {
throw new GitHubValidationError(
"Invalid pull request data",
422,
{ errors: error.errors }
);
}
throw error;
}
} }
export async function getPullRequest( export async function getPullRequest(
@ -124,11 +202,25 @@ export async function getPullRequest(
pullNumber: z.number().positive(), pullNumber: z.number().positive(),
}).parse(params); }).parse(params);
const response = await githubRequest( try {
`https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` const response = await githubRequest(
); `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}`
);
return GitHubPullRequestSchema.parse(response); return GitHubPullRequestSchema.parse(response);
} catch (error) {
if (error instanceof GitHubError) {
throw error;
}
if (error instanceof z.ZodError) {
throw new GitHubValidationError(
"Invalid pull request response data",
422,
{ errors: error.errors }
);
}
throw error;
}
} }
export async function listPullRequests( export async function listPullRequests(
@ -139,14 +231,28 @@ export async function listPullRequests(
...ListPullRequestsOptionsSchema.partial().shape, ...ListPullRequestsOptionsSchema.partial().shape,
}).parse(params); }).parse(params);
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); try {
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`);
Object.entries(options).forEach(([key, value]) => { Object.entries(options).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
url.searchParams.append(key, value.toString()); url.searchParams.append(key, value.toString());
}
});
const response = await githubRequest(url.toString());
return z.array(GitHubPullRequestSchema).parse(response);
} catch (error) {
if (error instanceof GitHubError) {
throw error;
} }
}); if (error instanceof z.ZodError) {
throw new GitHubValidationError(
const response = await githubRequest(url.toString()); "Invalid pull request list response data",
return z.array(GitHubPullRequestSchema).parse(response); 422,
{ errors: error.errors }
);
}
throw error;
}
} }