934 lines
28 KiB
Go
934 lines
28 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/go-viper/mapstructure/v2"
|
|
"github.com/google/go-github/v69/github"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/mark3labs/mcp-go/server"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
// GetIssue creates a tool to get details of a specific issue in a GitHub repository.
|
|
func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_issue",
|
|
mcp.WithDescription(t("TOOL_GET_ISSUE_DESCRIPTION", "Get details of a specific issue in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_ISSUE_USER_TITLE", "Get issue details"),
|
|
ReadOnlyHint: toBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("The owner of the repository"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("The name of the repository"),
|
|
),
|
|
mcp.WithNumber("issue_number",
|
|
mcp.Required(),
|
|
mcp.Description("The number of the issue"),
|
|
),
|
|
),
|
|
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
|
|
}
|
|
issueNumber, err := RequiredInt(request, "issue_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue: %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 issue: %s", string(body))), nil
|
|
}
|
|
|
|
r, err := json.Marshal(issue)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal issue: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// AddIssueComment creates a tool to add a comment to an issue.
|
|
func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("add_issue_comment",
|
|
mcp.WithDescription(t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"),
|
|
ReadOnlyHint: toBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithNumber("issue_number",
|
|
mcp.Required(),
|
|
mcp.Description("Issue number to comment on"),
|
|
),
|
|
mcp.WithString("body",
|
|
mcp.Required(),
|
|
mcp.Description("Comment content"),
|
|
),
|
|
),
|
|
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
|
|
}
|
|
issueNumber, err := RequiredInt(request, "issue_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
body, err := requiredParam[string](request, "body")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
comment := &github.IssueComment{
|
|
Body: github.Ptr(body),
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
createdComment, resp, err := client.Issues.CreateComment(ctx, owner, repo, issueNumber, comment)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create comment: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
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 comment: %s", string(body))), nil
|
|
}
|
|
|
|
r, err := json.Marshal(createdComment)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// SearchIssues creates a tool to search for issues and pull requests.
|
|
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("search_issues",
|
|
mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
|
|
ReadOnlyHint: toBoolPtr(true),
|
|
}),
|
|
mcp.WithString("q",
|
|
mcp.Required(),
|
|
mcp.Description("Search query using GitHub issues search syntax"),
|
|
),
|
|
mcp.WithString("sort",
|
|
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
|
|
mcp.Enum(
|
|
"comments",
|
|
"reactions",
|
|
"reactions-+1",
|
|
"reactions--1",
|
|
"reactions-smile",
|
|
"reactions-thinking_face",
|
|
"reactions-heart",
|
|
"reactions-tada",
|
|
"interactions",
|
|
"created",
|
|
"updated",
|
|
),
|
|
),
|
|
mcp.WithString("order",
|
|
mcp.Description("Sort order"),
|
|
mcp.Enum("asc", "desc"),
|
|
),
|
|
WithPagination(),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
query, err := requiredParam[string](request, "q")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
sort, err := OptionalParam[string](request, "sort")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
order, err := OptionalParam[string](request, "order")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
pagination, err := OptionalPaginationParams(request)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
opts := &github.SearchOptions{
|
|
Sort: sort,
|
|
Order: order,
|
|
ListOptions: github.ListOptions{
|
|
PerPage: pagination.perPage,
|
|
Page: pagination.page,
|
|
},
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
result, resp, err := client.Search.Issues(ctx, query, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to search issues: %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 search issues: %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
|
|
}
|
|
}
|
|
|
|
// CreateIssue creates a tool to create a new issue in a GitHub repository.
|
|
func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("create_issue",
|
|
mcp.WithDescription(t("TOOL_CREATE_ISSUE_DESCRIPTION", "Create a new issue in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Open new issue"),
|
|
ReadOnlyHint: toBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithString("title",
|
|
mcp.Required(),
|
|
mcp.Description("Issue title"),
|
|
),
|
|
mcp.WithString("body",
|
|
mcp.Description("Issue body content"),
|
|
),
|
|
mcp.WithArray("assignees",
|
|
mcp.Description("Usernames to assign to this issue"),
|
|
mcp.Items(
|
|
map[string]any{
|
|
"type": "string",
|
|
},
|
|
),
|
|
),
|
|
mcp.WithArray("labels",
|
|
mcp.Description("Labels to apply to this issue"),
|
|
mcp.Items(
|
|
map[string]any{
|
|
"type": "string",
|
|
},
|
|
),
|
|
),
|
|
mcp.WithNumber("milestone",
|
|
mcp.Description("Milestone 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
|
|
}
|
|
title, err := requiredParam[string](request, "title")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Optional parameters
|
|
body, err := OptionalParam[string](request, "body")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Get assignees
|
|
assignees, err := OptionalStringArrayParam(request, "assignees")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Get labels
|
|
labels, err := OptionalStringArrayParam(request, "labels")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Get optional milestone
|
|
milestone, err := OptionalIntParam(request, "milestone")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
var milestoneNum *int
|
|
if milestone != 0 {
|
|
milestoneNum = &milestone
|
|
}
|
|
|
|
// Create the issue request
|
|
issueRequest := &github.IssueRequest{
|
|
Title: github.Ptr(title),
|
|
Body: github.Ptr(body),
|
|
Assignees: &assignees,
|
|
Labels: &labels,
|
|
Milestone: milestoneNum,
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create issue: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
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 issue: %s", string(body))), nil
|
|
}
|
|
|
|
r, err := json.Marshal(issue)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// ListIssues creates a tool to list and filter repository issues
|
|
func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("list_issues",
|
|
mcp.WithDescription(t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"),
|
|
ReadOnlyHint: toBoolPtr(true),
|
|
}),
|
|
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"),
|
|
mcp.Enum("open", "closed", "all"),
|
|
),
|
|
mcp.WithArray("labels",
|
|
mcp.Description("Filter by labels"),
|
|
mcp.Items(
|
|
map[string]interface{}{
|
|
"type": "string",
|
|
},
|
|
),
|
|
),
|
|
mcp.WithString("sort",
|
|
mcp.Description("Sort order"),
|
|
mcp.Enum("created", "updated", "comments"),
|
|
),
|
|
mcp.WithString("direction",
|
|
mcp.Description("Sort direction"),
|
|
mcp.Enum("asc", "desc"),
|
|
),
|
|
mcp.WithString("since",
|
|
mcp.Description("Filter by date (ISO 8601 timestamp)"),
|
|
),
|
|
WithPagination(),
|
|
),
|
|
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
|
|
}
|
|
|
|
opts := &github.IssueListByRepoOptions{}
|
|
|
|
// Set optional parameters if provided
|
|
opts.State, err = OptionalParam[string](request, "state")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Get labels
|
|
opts.Labels, err = OptionalStringArrayParam(request, "labels")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
opts.Sort, err = OptionalParam[string](request, "sort")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
opts.Direction, err = OptionalParam[string](request, "direction")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
since, err := OptionalParam[string](request, "since")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if since != "" {
|
|
timestamp, err := parseISOTimestamp(since)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil
|
|
}
|
|
opts.Since = timestamp
|
|
}
|
|
|
|
if page, ok := request.GetArguments()["page"].(float64); ok {
|
|
opts.Page = int(page)
|
|
}
|
|
|
|
if perPage, ok := request.GetArguments()["perPage"].(float64); ok {
|
|
opts.PerPage = int(perPage)
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
issues, resp, err := client.Issues.ListByRepo(ctx, owner, repo, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list issues: %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 issues: %s", string(body))), nil
|
|
}
|
|
|
|
r, err := json.Marshal(issues)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal issues: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// UpdateIssue creates a tool to update an existing issue in a GitHub repository.
|
|
func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("update_issue",
|
|
mcp.WithDescription(t("TOOL_UPDATE_ISSUE_DESCRIPTION", "Update an existing issue in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_UPDATE_ISSUE_USER_TITLE", "Edit issue"),
|
|
ReadOnlyHint: toBoolPtr(false),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithNumber("issue_number",
|
|
mcp.Required(),
|
|
mcp.Description("Issue number to update"),
|
|
),
|
|
mcp.WithString("title",
|
|
mcp.Description("New title"),
|
|
),
|
|
mcp.WithString("body",
|
|
mcp.Description("New description"),
|
|
),
|
|
mcp.WithString("state",
|
|
mcp.Description("New state"),
|
|
mcp.Enum("open", "closed"),
|
|
),
|
|
mcp.WithArray("labels",
|
|
mcp.Description("New labels"),
|
|
mcp.Items(
|
|
map[string]interface{}{
|
|
"type": "string",
|
|
},
|
|
),
|
|
),
|
|
mcp.WithArray("assignees",
|
|
mcp.Description("New assignees"),
|
|
mcp.Items(
|
|
map[string]interface{}{
|
|
"type": "string",
|
|
},
|
|
),
|
|
),
|
|
mcp.WithNumber("milestone",
|
|
mcp.Description("New milestone 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
|
|
}
|
|
issueNumber, err := RequiredInt(request, "issue_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Create the issue request with only provided fields
|
|
issueRequest := &github.IssueRequest{}
|
|
|
|
// Set optional parameters if provided
|
|
title, err := OptionalParam[string](request, "title")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if title != "" {
|
|
issueRequest.Title = github.Ptr(title)
|
|
}
|
|
|
|
body, err := OptionalParam[string](request, "body")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if body != "" {
|
|
issueRequest.Body = github.Ptr(body)
|
|
}
|
|
|
|
state, err := OptionalParam[string](request, "state")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if state != "" {
|
|
issueRequest.State = github.Ptr(state)
|
|
}
|
|
|
|
// Get labels
|
|
labels, err := OptionalStringArrayParam(request, "labels")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if len(labels) > 0 {
|
|
issueRequest.Labels = &labels
|
|
}
|
|
|
|
// Get assignees
|
|
assignees, err := OptionalStringArrayParam(request, "assignees")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if len(assignees) > 0 {
|
|
issueRequest.Assignees = &assignees
|
|
}
|
|
|
|
milestone, err := OptionalIntParam(request, "milestone")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
if milestone != 0 {
|
|
milestoneNum := milestone
|
|
issueRequest.Milestone = &milestoneNum
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update issue: %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 update issue: %s", string(body))), nil
|
|
}
|
|
|
|
r, err := json.Marshal(updatedIssue)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal response: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText(string(r)), nil
|
|
}
|
|
}
|
|
|
|
// GetIssueComments creates a tool to get comments for a GitHub issue.
|
|
func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("get_issue_comments",
|
|
mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a specific issue in a GitHub repository.")),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_GET_ISSUE_COMMENTS_USER_TITLE", "Get issue comments"),
|
|
ReadOnlyHint: toBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithNumber("issue_number",
|
|
mcp.Required(),
|
|
mcp.Description("Issue number"),
|
|
),
|
|
mcp.WithNumber("page",
|
|
mcp.Description("Page number"),
|
|
),
|
|
mcp.WithNumber("per_page",
|
|
mcp.Description("Number of records per page"),
|
|
),
|
|
),
|
|
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
|
|
}
|
|
issueNumber, err := RequiredInt(request, "issue_number")
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
page, err := OptionalIntParamWithDefault(request, "page", 1)
|
|
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
|
|
}
|
|
|
|
opts := &github.IssueListCommentsOptions{
|
|
ListOptions: github.ListOptions{
|
|
Page: page,
|
|
PerPage: perPage,
|
|
},
|
|
}
|
|
|
|
client, err := getClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get issue 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 issue 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
|
|
}
|
|
}
|
|
|
|
// mvpDescription is an MVP idea for generating tool descriptions from structured data in a shared format.
|
|
// It is not intended for widespread usage and is not a complete implementation.
|
|
type mvpDescription struct {
|
|
summary string
|
|
outcomes []string
|
|
referenceLinks []string
|
|
}
|
|
|
|
func (d *mvpDescription) String() string {
|
|
var sb strings.Builder
|
|
sb.WriteString(d.summary)
|
|
if len(d.outcomes) > 0 {
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString("This tool can help with the following outcomes:\n")
|
|
for _, outcome := range d.outcomes {
|
|
sb.WriteString(fmt.Sprintf("- %s\n", outcome))
|
|
}
|
|
}
|
|
|
|
if len(d.referenceLinks) > 0 {
|
|
sb.WriteString("\n\n")
|
|
sb.WriteString("More information can be found at:\n")
|
|
for _, link := range d.referenceLinks {
|
|
sb.WriteString(fmt.Sprintf("- %s\n", link))
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
|
|
description := mvpDescription{
|
|
summary: "Assign Copilot to a specific issue in a GitHub repository.",
|
|
outcomes: []string{
|
|
"a Pull Request created with source code changes to resolve the issue",
|
|
},
|
|
referenceLinks: []string{
|
|
"https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot",
|
|
},
|
|
}
|
|
|
|
return mcp.NewTool("assign_copilot_to_issue",
|
|
mcp.WithDescription(t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String())),
|
|
mcp.WithToolAnnotation(mcp.ToolAnnotation{
|
|
Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"),
|
|
ReadOnlyHint: toBoolPtr(false),
|
|
IdempotentHint: toBoolPtr(true),
|
|
}),
|
|
mcp.WithString("owner",
|
|
mcp.Required(),
|
|
mcp.Description("Repository owner"),
|
|
),
|
|
mcp.WithString("repo",
|
|
mcp.Required(),
|
|
mcp.Description("Repository name"),
|
|
),
|
|
mcp.WithNumber("issueNumber",
|
|
mcp.Required(),
|
|
mcp.Description("Issue number"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
var params struct {
|
|
Owner string
|
|
Repo string
|
|
IssueNumber int32
|
|
}
|
|
if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
client, err := getGQLClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
|
|
}
|
|
|
|
// Firstly, we try to find the copilot bot in the suggested actors for the repository.
|
|
// Although as I write this, we would expect copilot to be at the top of the list, in future, maybe
|
|
// it will not be on the first page of responses, thus we will keep paginating until we find it.
|
|
type botAssignee struct {
|
|
ID githubv4.ID
|
|
Login string
|
|
TypeName string `graphql:"__typename"`
|
|
}
|
|
|
|
type suggestedActorsQuery struct {
|
|
Repository struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Bot botAssignee `graphql:"... on Bot"`
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
} `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables := map[string]any{
|
|
"owner": githubv4.String(params.Owner),
|
|
"name": githubv4.String(params.Repo),
|
|
"endCursor": (*githubv4.String)(nil),
|
|
}
|
|
|
|
var copilotAssignee *botAssignee
|
|
for {
|
|
var query suggestedActorsQuery
|
|
err := client.Query(ctx, &query, variables)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Iterate all the returned nodes looking for the copilot bot, which is supposed to have the
|
|
// same name on each host. We need this in order to get the ID for later assignment.
|
|
for _, node := range query.Repository.SuggestedActors.Nodes {
|
|
if node.Bot.Login == "copilot-swe-agent" {
|
|
copilotAssignee = &node.Bot
|
|
break
|
|
}
|
|
}
|
|
|
|
if !query.Repository.SuggestedActors.PageInfo.HasNextPage {
|
|
break
|
|
}
|
|
variables["endCursor"] = githubv4.String(query.Repository.SuggestedActors.PageInfo.EndCursor)
|
|
}
|
|
|
|
// If we didn't find the copilot bot, we can't proceed any further.
|
|
if copilotAssignee == nil {
|
|
// The e2e tests depend upon this specific message to skip the test.
|
|
return mcp.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil
|
|
}
|
|
|
|
// Next let's get the GQL Node ID and current assignees for this issue because the only way to
|
|
// assign copilot is to use replaceActorsForAssignable which requires the full list.
|
|
var getIssueQuery struct {
|
|
Repository struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Assignees struct {
|
|
Nodes []struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"assignees(first: 100)"`
|
|
} `graphql:"issue(number: $number)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables = map[string]any{
|
|
"owner": githubv4.String(params.Owner),
|
|
"name": githubv4.String(params.Repo),
|
|
"number": githubv4.Int(params.IssueNumber),
|
|
}
|
|
|
|
if err := client.Query(ctx, &getIssueQuery, variables); err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil
|
|
}
|
|
|
|
// Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already
|
|
// assigned to seems to have no impact (which is a good thing).
|
|
var assignCopilotMutation struct {
|
|
ReplaceActorsForAssignable struct {
|
|
Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors
|
|
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
|
}
|
|
|
|
actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1)
|
|
for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes {
|
|
actorIDs[i] = node.ID
|
|
}
|
|
actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID
|
|
|
|
if err := client.Mutate(
|
|
ctx,
|
|
&assignCopilotMutation,
|
|
ReplaceActorsForAssignableInput{
|
|
AssignableID: getIssueQuery.Repository.Issue.ID,
|
|
ActorIDs: actorIDs,
|
|
},
|
|
nil,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("failed to replace actors for assignable: %w", err)
|
|
}
|
|
|
|
return mcp.NewToolResultText("successfully assigned copilot to issue"), nil
|
|
}
|
|
}
|
|
|
|
type ReplaceActorsForAssignableInput struct {
|
|
AssignableID githubv4.ID `json:"assignableId"`
|
|
ActorIDs []githubv4.ID `json:"actorIds"`
|
|
}
|
|
|
|
// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
|
|
// Returns the parsed time or an error if parsing fails.
|
|
// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
|
|
func parseISOTimestamp(timestamp string) (time.Time, error) {
|
|
if timestamp == "" {
|
|
return time.Time{}, fmt.Errorf("empty timestamp")
|
|
}
|
|
|
|
// Try RFC3339 format (standard ISO 8601 with time)
|
|
t, err := time.Parse(time.RFC3339, timestamp)
|
|
if err == nil {
|
|
return t, nil
|
|
}
|
|
|
|
// Try simple date format (YYYY-MM-DD)
|
|
t, err = time.Parse("2006-01-02", timestamp)
|
|
if err == nil {
|
|
return t, nil
|
|
}
|
|
|
|
// Return error with supported formats
|
|
return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp)
|
|
}
|