Files
github--github-mcp-server/pkg/github/pullrequests.go
T
2025-03-24 17:28:21 +01:00

713 lines
23 KiB
Go

package github
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v69/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// getPullRequest creates a tool to get details of a specific pull request.
func getPullRequest(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request",
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_DESCRIPTION", "Get details of a specific pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
return nil, fmt.Errorf("failed to get pull request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil
}
r, err := json.Marshal(pr)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// listPullRequests creates a tool to list and filter repository pull requests.
func listPullRequests(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_pull_requests",
mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List and filter repository pull requests")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithString("state",
mcp.Description("Filter by state ('open', 'closed', 'all')"),
),
mcp.WithString("head",
mcp.Description("Filter by head user/org and branch"),
),
mcp.WithString("base",
mcp.Description("Filter by base branch"),
),
mcp.WithString("sort",
mcp.Description("Sort by ('created', 'updated', 'popularity', 'long-running')"),
),
mcp.WithString("direction",
mcp.Description("Sort direction ('asc', 'desc')"),
),
mcp.WithNumber("per_page",
mcp.Description("Results per page (max 100)"),
),
mcp.WithNumber("page",
mcp.Description("Page number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
state, err := optionalParam[string](request, "state")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
head, err := optionalParam[string](request, "head")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
base, err := optionalParam[string](request, "base")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
sort, err := optionalParam[string](request, "sort")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
direction, err := optionalParam[string](request, "direction")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
perPage, err := optionalIntParamWithDefault(request, "per_page", 30)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
page, err := optionalIntParamWithDefault(request, "page", 1)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.PullRequestListOptions{
State: state,
Head: head,
Base: base,
Sort: sort,
Direction: direction,
ListOptions: github.ListOptions{
PerPage: perPage,
Page: page,
},
}
prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts)
if err != nil {
return nil, fmt.Errorf("failed to list pull requests: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to list pull requests: %s", string(body))), nil
}
r, err := json.Marshal(prs)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// mergePullRequest creates a tool to merge a pull request.
func mergePullRequest(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("merge_pull_request",
mcp.WithDescription(t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
mcp.WithString("commit_title",
mcp.Description("Title for merge commit"),
),
mcp.WithString("commit_message",
mcp.Description("Extra detail for merge commit"),
),
mcp.WithString("merge_method",
mcp.Description("Merge method ('merge', 'squash', 'rebase')"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
commitTitle, err := optionalParam[string](request, "commit_title")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
commitMessage, err := optionalParam[string](request, "commit_message")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
mergeMethod, err := optionalParam[string](request, "merge_method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
options := &github.PullRequestOptions{
CommitTitle: commitTitle,
MergeMethod: mergeMethod,
}
result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options)
if err != nil {
return nil, fmt.Errorf("failed to merge pull request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to merge pull request: %s", string(body))), nil
}
r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// getPullRequestFiles creates a tool to get the list of files changed in a pull request.
func getPullRequestFiles(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_files",
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_FILES_DESCRIPTION", "Get the list of files changed in a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.ListOptions{}
files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts)
if err != nil {
return nil, fmt.Errorf("failed to get pull request files: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request files: %s", string(body))), nil
}
r, err := json.Marshal(files)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// getPullRequestStatus creates a tool to get the combined status of all status checks for a pull request.
func getPullRequestStatus(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_status",
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_STATUS_DESCRIPTION", "Get the combined status of all status checks for a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// First get the PR to find the head SHA
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
return nil, fmt.Errorf("failed to get pull request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request: %s", string(body))), nil
}
// Get combined status for the head SHA
status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil)
if err != nil {
return nil, fmt.Errorf("failed to get combined status: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get combined status: %s", string(body))), nil
}
r, err := json.Marshal(status)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// updatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch.
func updatePullRequestBranch(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("update_pull_request_branch",
mcp.WithDescription(t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update a pull request branch with the latest changes from the base branch")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
mcp.WithString("expected_head_sha",
mcp.Description("The expected SHA of the pull request's HEAD ref"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
expectedHeadSHA, err := optionalParam[string](request, "expected_head_sha")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.PullRequestBranchUpdateOptions{}
if expectedHeadSHA != "" {
opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA)
}
result, resp, err := client.PullRequests.UpdateBranch(ctx, owner, repo, pullNumber, opts)
if err != nil {
// Check if it's an acceptedError. An acceptedError indicates that the update is in progress,
// and it's not a real error.
if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) {
return mcp.NewToolResultText("Pull request branch update is in progress"), nil
}
return nil, fmt.Errorf("failed to update pull request branch: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusAccepted {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to update pull request branch: %s", string(body))), nil
}
r, err := json.Marshal(result)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// getPullRequestComments creates a tool to get the review comments on a pull request.
func getPullRequestComments(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_comments",
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_COMMENTS_DESCRIPTION", "Get the review comments on a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.PullRequestListCommentsOptions{
ListOptions: github.ListOptions{
PerPage: 100,
},
}
comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts)
if err != nil {
return nil, fmt.Errorf("failed to get pull request comments: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request comments: %s", string(body))), nil
}
r, err := json.Marshal(comments)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// getPullRequestReviews creates a tool to get the reviews on a pull request.
func getPullRequestReviews(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_reviews",
mcp.WithDescription(t("TOOL_GET_PULL_REQUEST_REVIEWS_DESCRIPTION", "Get the reviews on a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
if err != nil {
return nil, fmt.Errorf("failed to get pull request reviews: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to get pull request reviews: %s", string(body))), nil
}
r, err := json.Marshal(reviews)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}
// createPullRequestReview creates a tool to submit a review on a pull request.
func createPullRequestReview(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_pull_request_review",
mcp.WithDescription(t("TOOL_CREATE_PULL_REQUEST_REVIEW_DESCRIPTION", "Create a review on a pull request")),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
mcp.WithNumber("pull_number",
mcp.Required(),
mcp.Description("Pull request number"),
),
mcp.WithString("body",
mcp.Description("Review comment text"),
),
mcp.WithString("event",
mcp.Required(),
mcp.Description("Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT')"),
),
mcp.WithString("commit_id",
mcp.Description("SHA of commit to review"),
),
mcp.WithArray("comments",
mcp.Items(
map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "path to the file",
},
"position": map[string]interface{}{
"type": "number",
"description": "line number in the file",
},
"body": map[string]interface{}{
"type": "string",
"description": "comment body",
},
},
},
),
mcp.Description("Line-specific comments array of objects, each object with path (string), position (number), and body (string)"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := requiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
repo, err := requiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
pullNumber, err := requiredInt(request, "pull_number")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
event, err := requiredParam[string](request, "event")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
// Create review request
reviewRequest := &github.PullRequestReviewRequest{
Event: github.Ptr(event),
}
// Add body if provided
body, err := optionalParam[string](request, "body")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if body != "" {
reviewRequest.Body = github.Ptr(body)
}
// Add commit ID if provided
commitID, err := optionalParam[string](request, "commit_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if commitID != "" {
reviewRequest.CommitID = github.Ptr(commitID)
}
// Add comments if provided
if commentsObj, ok := request.Params.Arguments["comments"].([]interface{}); ok && len(commentsObj) > 0 {
comments := []*github.DraftReviewComment{}
for _, c := range commentsObj {
commentMap, ok := c.(map[string]interface{})
if !ok {
return mcp.NewToolResultError("each comment must be an object with path, position, and body"), nil
}
path, ok := commentMap["path"].(string)
if !ok || path == "" {
return mcp.NewToolResultError("each comment must have a path"), nil
}
positionFloat, ok := commentMap["position"].(float64)
if !ok {
return mcp.NewToolResultError("each comment must have a position"), nil
}
position := int(positionFloat)
body, ok := commentMap["body"].(string)
if !ok || body == "" {
return mcp.NewToolResultError("each comment must have a body"), nil
}
comments = append(comments, &github.DraftReviewComment{
Path: github.Ptr(path),
Position: github.Ptr(position),
Body: github.Ptr(body),
})
}
reviewRequest.Comments = comments
}
review, resp, err := client.PullRequests.CreateReview(ctx, owner, repo, pullNumber, reviewRequest)
if err != nil {
return nil, fmt.Errorf("failed to create pull request review: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return mcp.NewToolResultError(fmt.Sprintf("failed to create pull request review: %s", string(body))), nil
}
r, err := json.Marshal(review)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
}
return mcp.NewToolResultText(string(r)), nil
}
}