diff --git a/src/github/README.md b/src/github/README.md index d2277ab..e06bf14 100644 --- a/src/github/README.md +++ b/src/github/README.md @@ -188,6 +188,95 @@ MCP Server for the GitHub API, enabling file operations, repository management, - `issue_number` (number): Issue number to retrieve - Returns: Github Issue object & details +18. `get_pull_request` + - Get details of a specific pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Pull request details including diff and review status + +19. `list_pull_requests` + - List and filter repository pull requests + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `state` (optional string): Filter by state ('open', 'closed', 'all') + - `head` (optional string): Filter by head user/org and branch + - `base` (optional string): Filter by base branch + - `sort` (optional string): Sort by ('created', 'updated', 'popularity', 'long-running') + - `direction` (optional string): Sort direction ('asc', 'desc') + - `per_page` (optional number): Results per page (max 100) + - `page` (optional number): Page number + - Returns: Array of pull request details + +20. `create_pull_request_review` + - Create a review on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `body` (string): Review comment text + - `event` (string): Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') + - `commit_id` (optional string): SHA of commit to review + - `comments` (optional array): Line-specific comments, each with: + - `path` (string): File path + - `position` (number): Line position in diff + - `body` (string): Comment text + - Returns: Created review details + +21. `merge_pull_request` + - Merge a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `commit_title` (optional string): Title for merge commit + - `commit_message` (optional string): Extra detail for merge commit + - `merge_method` (optional string): Merge method ('merge', 'squash', 'rebase') + - Returns: Merge result details + +22. `get_pull_request_files` + - Get the list of files changed in a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of changed files with patch and status details + +23. `get_pull_request_status` + - Get the combined status of all status checks for a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Combined status check results and individual check details + +24. `update_pull_request_branch` + - Update a pull request branch with the latest changes from the base branch (equivalent to GitHub's "Update branch" button) + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - `expected_head_sha` (optional string): The expected SHA of the pull request's HEAD ref + - Returns: Success message when branch is updated + +25. `get_pull_request_comments` + - Get the review comments on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of pull request review comments with details like the comment text, author, and location in the diff + +26. `get_pull_request_reviews` + - Get the reviews on a pull request + - Inputs: + - `owner` (string): Repository owner + - `repo` (string): Repository name + - `pull_number` (number): Pull request number + - Returns: Array of pull request reviews with details like the review state (APPROVED, CHANGES_REQUESTED, etc.), reviewer, and review body + ## Search Query Syntax ### Code Search diff --git a/src/github/common/types.ts b/src/github/common/types.ts index af81f22..cca961b 100644 --- a/src/github/common/types.ts +++ b/src/github/common/types.ts @@ -204,6 +204,42 @@ export const GitHubSearchResponseSchema = z.object({ items: z.array(GitHubRepositorySchema), }); +// Pull request schemas +export const GitHubPullRequestRefSchema = z.object({ + label: z.string(), + ref: z.string(), + sha: z.string(), + user: GitHubIssueAssigneeSchema, + repo: GitHubRepositorySchema, +}); + +export const GitHubPullRequestSchema = z.object({ + url: z.string(), + id: z.number(), + node_id: z.string(), + html_url: z.string(), + diff_url: z.string(), + patch_url: z.string(), + issue_url: z.string(), + number: z.number(), + state: z.string(), + locked: z.boolean(), + title: z.string(), + user: GitHubIssueAssigneeSchema, + body: z.string().nullable(), + created_at: z.string(), + updated_at: z.string(), + closed_at: z.string().nullable(), + merged_at: z.string().nullable(), + merge_commit_sha: z.string().nullable(), + assignee: GitHubIssueAssigneeSchema.nullable(), + assignees: z.array(GitHubIssueAssigneeSchema), + requested_reviewers: z.array(GitHubIssueAssigneeSchema), + labels: z.array(GitHubLabelSchema), + head: GitHubPullRequestRefSchema, + base: GitHubPullRequestRefSchema, +}); + // Export types export type GitHubAuthor = z.infer; export type GitHubRepository = z.infer; @@ -218,4 +254,6 @@ export type GitHubIssueAssignee = z.infer; export type GitHubLabel = z.infer; export type GitHubMilestone = z.infer; export type GitHubIssue = z.infer; -export type GitHubSearchResponse = z.infer; \ No newline at end of file +export type GitHubSearchResponse = z.infer; +export type GitHubPullRequest = z.infer; +export type GitHubPullRequestRef = z.infer; \ No newline at end of file diff --git a/src/github/operations/pulls.ts b/src/github/operations/pulls.ts index a3e05e6..9b1a5bd 100644 --- a/src/github/operations/pulls.ts +++ b/src/github/operations/pulls.ts @@ -1,261 +1,302 @@ import { z } from "zod"; +import { githubRequest } from "../common/utils.js"; import { - githubRequest, - validateBranchName, - validateOwnerName, - validateRepositoryName, - checkBranchExists, -} from "../common/utils.js"; -import { - GitHubIssueAssigneeSchema, - GitHubRepositorySchema + GitHubPullRequestSchema, + GitHubIssueAssigneeSchema, + GitHubRepositorySchema, } from "../common/types.js"; -import { - GitHubError, - GitHubValidationError, - GitHubResourceNotFoundError, - GitHubConflictError, -} from "../common/errors.js"; -const GITHUB_TITLE_MAX_LENGTH = 256; -const GITHUB_BODY_MAX_LENGTH = 65536; - -export const RepositoryParamsSchema = z.object({ - owner: z.string().min(1).transform(validateOwnerName), - repo: z.string().min(1).transform(validateRepositoryName), +// Schema definitions +export const PullRequestFileSchema = z.object({ + sha: z.string(), + filename: z.string(), + status: z.enum(['added', 'removed', 'modified', 'renamed', 'copied', 'changed', 'unchanged']), + additions: z.number(), + deletions: z.number(), + changes: z.number(), + blob_url: z.string(), + raw_url: z.string(), + contents_url: z.string(), + patch: z.string().optional() }); -export const GitHubPullRequestStateSchema = z.enum([ - "open", - "closed", - "merged", - "draft" -]); - -export const GitHubPullRequestSortSchema = z.enum([ - "created", - "updated", - "popularity", - "long-running" -]); - -export const GitHubDirectionSchema = z.enum([ - "asc", - "desc" -]); - -export const GitHubPullRequestRefSchema = z.object({ - label: z.string(), - ref: z.string().min(1).transform(validateBranchName), - sha: z.string().length(40), - user: GitHubIssueAssigneeSchema, - repo: GitHubRepositorySchema, +export const StatusCheckSchema = z.object({ + url: z.string(), + state: z.enum(['error', 'failure', 'pending', 'success']), + description: z.string().nullable(), + target_url: z.string().nullable(), + context: z.string(), + created_at: z.string(), + updated_at: z.string() }); -export const GitHubPullRequestSchema = z.object({ - url: z.string().url(), - id: z.number().positive(), +export const CombinedStatusSchema = z.object({ + state: z.enum(['error', 'failure', 'pending', 'success']), + statuses: z.array(StatusCheckSchema), + sha: z.string(), + total_count: z.number() +}); + +export const PullRequestCommentSchema = z.object({ + url: z.string(), + id: z.number(), node_id: z.string(), - html_url: z.string().url(), - diff_url: z.string().url(), - patch_url: z.string().url(), - issue_url: z.string().url(), - number: z.number().positive(), - state: GitHubPullRequestStateSchema, - locked: z.boolean(), - title: z.string().max(GITHUB_TITLE_MAX_LENGTH), + pull_request_review_id: z.number().nullable(), + diff_hunk: z.string(), + path: z.string().nullable(), + position: z.number().nullable(), + original_position: z.number().nullable(), + commit_id: z.string(), + original_commit_id: z.string(), user: GitHubIssueAssigneeSchema, - body: z.string().max(GITHUB_BODY_MAX_LENGTH).nullable(), - created_at: z.string().datetime(), - updated_at: z.string().datetime(), - closed_at: z.string().datetime().nullable(), - merged_at: z.string().datetime().nullable(), - merge_commit_sha: z.string().length(40).nullable(), - assignee: GitHubIssueAssigneeSchema.nullable(), - assignees: z.array(GitHubIssueAssigneeSchema), - requested_reviewers: z.array(GitHubIssueAssigneeSchema), - labels: z.array(z.object({ - name: z.string(), - color: z.string().regex(/^[0-9a-fA-F]{6}$/), - description: z.string().nullable(), - })), - head: GitHubPullRequestRefSchema, - base: GitHubPullRequestRefSchema, + body: z.string(), + created_at: z.string(), + updated_at: z.string(), + html_url: z.string(), + pull_request_url: z.string(), + author_association: z.string(), + _links: z.object({ + self: z.object({ href: z.string() }), + html: z.object({ href: z.string() }), + pull_request: z.object({ href: z.string() }) + }) }); -export const ListPullRequestsOptionsSchema = z.object({ - state: GitHubPullRequestStateSchema.optional(), - head: z.string().transform(validateBranchName).optional(), - base: z.string().transform(validateBranchName).optional(), - sort: GitHubPullRequestSortSchema.optional(), - direction: GitHubDirectionSchema.optional(), - per_page: z.number().min(1).max(100).optional(), - page: z.number().min(1).optional(), +export const PullRequestReviewSchema = z.object({ + id: z.number(), + node_id: z.string(), + user: GitHubIssueAssigneeSchema, + body: z.string().nullable(), + state: z.enum(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']), + html_url: z.string(), + pull_request_url: z.string(), + commit_id: z.string(), + submitted_at: z.string().nullable(), + author_association: z.string() }); -export const CreatePullRequestOptionsSchema = z.object({ - title: z.string().max(GITHUB_TITLE_MAX_LENGTH), - body: z.string().max(GITHUB_BODY_MAX_LENGTH).optional(), - head: z.string().min(1).transform(validateBranchName), - base: z.string().min(1).transform(validateBranchName), - maintainer_can_modify: z.boolean().optional(), - draft: z.boolean().optional(), +// Input schemas +export const CreatePullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + title: z.string().describe("Pull request title"), + body: z.string().optional().describe("Pull request body/description"), + head: z.string().describe("The name of the branch where your changes are implemented"), + base: z.string().describe("The name of the branch you want the changes pulled into"), + draft: z.boolean().optional().describe("Whether to create the pull request as a draft"), + maintainer_can_modify: z.boolean().optional().describe("Whether maintainers can modify the pull request") }); -export const CreatePullRequestSchema = RepositoryParamsSchema.extend({ - ...CreatePullRequestOptionsSchema.shape, +export const GetPullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") }); -export type RepositoryParams = z.infer; -export type CreatePullRequestOptions = z.infer; -export type ListPullRequestsOptions = z.infer; -export type GitHubPullRequest = z.infer; -export type GitHubPullRequestRef = z.infer; +export const ListPullRequestsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + state: z.enum(['open', 'closed', 'all']).optional().describe("State of the pull requests to return"), + head: z.string().optional().describe("Filter by head user or head organization and branch name"), + base: z.string().optional().describe("Filter by base branch name"), + sort: z.enum(['created', 'updated', 'popularity', 'long-running']).optional().describe("What to sort results by"), + direction: z.enum(['asc', 'desc']).optional().describe("The direction of the sort"), + per_page: z.number().optional().describe("Results per page (max 100)"), + page: z.number().optional().describe("Page number of the results") +}); -async function validatePullRequestBranches( - owner: string, - repo: string, - head: string, - base: string -): Promise { - const [headExists, baseExists] = await Promise.all([ - checkBranchExists(owner, repo, head), - checkBranchExists(owner, repo, base), - ]); +export const CreatePullRequestReviewSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + commit_id: z.string().optional().describe("The SHA of the commit that needs a review"), + body: z.string().describe("The body text of the review"), + event: z.enum(['APPROVE', 'REQUEST_CHANGES', 'COMMENT']).describe("The review action to perform"), + comments: z.array(z.object({ + path: z.string().describe("The relative path to the file being commented on"), + position: z.number().describe("The position in the diff where you want to add a review comment"), + body: z.string().describe("Text of the review comment") + })).optional().describe("Comments to post as part of the review") +}); - if (!headExists) { - throw new GitHubResourceNotFoundError(`Branch '${head}' not found`); - } +export const MergePullRequestSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + commit_title: z.string().optional().describe("Title for the automatic commit message"), + commit_message: z.string().optional().describe("Extra detail to append to automatic commit message"), + merge_method: z.enum(['merge', 'squash', 'rebase']).optional().describe("Merge method to use") +}); - if (!baseExists) { - throw new GitHubResourceNotFoundError(`Branch '${base}' not found`); - } +export const GetPullRequestFilesSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); - if (head === base) { - throw new GitHubValidationError( - "Head and base branches cannot be the same", - 422, - { message: "Head and base branches must be different" } - ); - } -} +export const GetPullRequestStatusSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); -async function checkForExistingPullRequest( - owner: string, - repo: string, - head: string, - base: string -): Promise { - const existingPRs = await listPullRequests({ - owner, - repo, - state: "open", - }); +export const UpdatePullRequestBranchSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number"), + expected_head_sha: z.string().optional().describe("The expected SHA of the pull request's HEAD ref") +}); - // Check if any existing open PR has the exact same head and base combination - const duplicatePR = existingPRs.find(pr => - pr.head.ref === head && pr.base.ref === base - ); +export const GetPullRequestCommentsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); - if (duplicatePR) { - throw new GitHubConflictError( - `A pull request already exists for ${head} into ${base}` - ); - } -} +export const GetPullRequestReviewsSchema = z.object({ + owner: z.string().describe("Repository owner (username or organization)"), + repo: z.string().describe("Repository name"), + pull_number: z.number().describe("Pull request number") +}); +// Function implementations export async function createPullRequest( params: z.infer -): Promise { +): Promise> { const { owner, repo, ...options } = CreatePullRequestSchema.parse(params); - try { - await validatePullRequestBranches(owner, repo, options.head, options.base); - await checkForExistingPullRequest(owner, repo, options.head, options.base); - - 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; + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls`, + { + method: "POST", + body: options, } - if (error instanceof z.ZodError) { - throw new GitHubValidationError( - "Invalid pull request data", - 422, - { errors: error.errors } - ); - } - throw error; - } + ); + + return GitHubPullRequestSchema.parse(response); } export async function getPullRequest( - params: RepositoryParams & { pullNumber: number } -): Promise { - const { owner, repo, pullNumber } = z.object({ - ...RepositoryParamsSchema.shape, - pullNumber: z.number().positive(), - }).parse(params); - - try { - const response = await githubRequest( - `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` - ); - - 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; - } + owner: string, + repo: string, + pullNumber: number +): Promise> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}` + ); + return GitHubPullRequestSchema.parse(response); } export async function listPullRequests( - params: RepositoryParams & Partial -): Promise { - const { owner, repo, ...options } = z.object({ - ...RepositoryParamsSchema.shape, - ...ListPullRequestsOptionsSchema.partial().shape, - }).parse(params); + owner: string, + repo: string, + options: Omit, 'owner' | 'repo'> +): Promise[]> { + const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); + + if (options.state) url.searchParams.append('state', options.state); + if (options.head) url.searchParams.append('head', options.head); + if (options.base) url.searchParams.append('base', options.base); + if (options.sort) url.searchParams.append('sort', options.sort); + if (options.direction) url.searchParams.append('direction', options.direction); + if (options.per_page) url.searchParams.append('per_page', options.per_page.toString()); + if (options.page) url.searchParams.append('page', options.page.toString()); - try { - const url = new URL(`https://api.github.com/repos/${owner}/${repo}/pulls`); - - Object.entries(options).forEach(([key, value]) => { - if (value !== undefined) { - url.searchParams.append(key, value.toString()); - } - }); + const response = await githubRequest(url.toString()); + return z.array(GitHubPullRequestSchema).parse(response); +} - const response = await githubRequest(url.toString()); - return z.array(GitHubPullRequestSchema).parse(response); - } catch (error) { - if (error instanceof GitHubError) { - throw error; +export async function createPullRequestReview( + owner: string, + repo: string, + pullNumber: number, + options: Omit, 'owner' | 'repo' | 'pull_number'> +): Promise> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews`, + { + method: 'POST', + body: options, } - if (error instanceof z.ZodError) { - throw new GitHubValidationError( - "Invalid pull request list response data", - 422, - { errors: error.errors } - ); + ); + return PullRequestReviewSchema.parse(response); +} + +export async function mergePullRequest( + owner: string, + repo: string, + pullNumber: number, + options: Omit, 'owner' | 'repo' | 'pull_number'> +): Promise { + return githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/merge`, + { + method: 'PUT', + body: options, } - throw error; - } + ); +} + +export async function getPullRequestFiles( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/files` + ); + return z.array(PullRequestFileSchema).parse(response); +} + +export async function updatePullRequestBranch( + owner: string, + repo: string, + pullNumber: number, + expectedHeadSha?: string +): Promise { + await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/update-branch`, + { + method: "PUT", + body: expectedHeadSha ? { expected_head_sha: expectedHeadSha } : undefined, + } + ); +} + +export async function getPullRequestComments( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/comments` + ); + return z.array(PullRequestCommentSchema).parse(response); +} + +export async function getPullRequestReviews( + owner: string, + repo: string, + pullNumber: number +): Promise[]> { + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pullNumber}/reviews` + ); + return z.array(PullRequestReviewSchema).parse(response); +} + +export async function getPullRequestStatus( + owner: string, + repo: string, + pullNumber: number +): Promise> { + // First get the PR to get the head SHA + const pr = await getPullRequest(owner, repo, pullNumber); + const sha = pr.head.sha; + + // Then get the combined status for that SHA + const response = await githubRequest( + `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/status` + ); + return CombinedStatusSchema.parse(response); } \ No newline at end of file