481 lines
15 KiB
Go
481 lines
15 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// getIssue creates a tool to get details of a specific issue in a GitHub repository.
|
|
func getIssue(client *github.Client, 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.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 := request.Params.Arguments["owner"].(string)
|
|
repo := request.Params.Arguments["repo"].(string)
|
|
issueNumber := int(request.Params.Arguments["issue_number"].(float64))
|
|
|
|
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(client *github.Client, 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 an existing issue")),
|
|
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 text"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner := request.Params.Arguments["owner"].(string)
|
|
repo := request.Params.Arguments["repo"].(string)
|
|
issueNumber := int(request.Params.Arguments["issue_number"].(float64))
|
|
body := request.Params.Arguments["body"].(string)
|
|
|
|
comment := &github.IssueComment{
|
|
Body: github.Ptr(body),
|
|
}
|
|
|
|
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(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
|
|
return mcp.NewTool("search_issues",
|
|
mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues and pull requests across GitHub repositories")),
|
|
mcp.WithString("q",
|
|
mcp.Required(),
|
|
mcp.Description("Search query using GitHub issues search syntax"),
|
|
),
|
|
mcp.WithString("sort",
|
|
mcp.Description("Sort field (comments, reactions, created, etc.)"),
|
|
),
|
|
mcp.WithString("order",
|
|
mcp.Description("Sort order ('asc' or '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) {
|
|
query := request.Params.Arguments["q"].(string)
|
|
sort := ""
|
|
if s, ok := request.Params.Arguments["sort"].(string); ok {
|
|
sort = s
|
|
}
|
|
order := ""
|
|
if o, ok := request.Params.Arguments["order"].(string); ok {
|
|
order = o
|
|
}
|
|
perPage := 30
|
|
if pp, ok := request.Params.Arguments["per_page"].(float64); ok {
|
|
perPage = int(pp)
|
|
}
|
|
page := 1
|
|
if p, ok := request.Params.Arguments["page"].(float64); ok {
|
|
page = int(p)
|
|
}
|
|
|
|
opts := &github.SearchOptions{
|
|
Sort: sort,
|
|
Order: order,
|
|
ListOptions: github.ListOptions{
|
|
PerPage: perPage,
|
|
Page: page,
|
|
},
|
|
}
|
|
|
|
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(client *github.Client, 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.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.WithString("assignees",
|
|
mcp.Description("Comma-separate list of usernames to assign to this issue"),
|
|
),
|
|
mcp.WithString("labels",
|
|
mcp.Description("Comma-separate list of labels to apply to this issue"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner := request.Params.Arguments["owner"].(string)
|
|
repo := request.Params.Arguments["repo"].(string)
|
|
title := request.Params.Arguments["title"].(string)
|
|
|
|
// Optional parameters
|
|
var body string
|
|
if b, ok := request.Params.Arguments["body"].(string); ok {
|
|
body = b
|
|
}
|
|
|
|
// Parse assignees if present
|
|
assignees := []string{} // default to empty slice, can't be nil
|
|
if a, ok := request.Params.Arguments["assignees"].(string); ok && a != "" {
|
|
assignees = parseCommaSeparatedList(a)
|
|
}
|
|
|
|
// Parse labels if present
|
|
labels := []string{} // default to empty slice, can't be nil
|
|
if l, ok := request.Params.Arguments["labels"].(string); ok && l != "" {
|
|
labels = parseCommaSeparatedList(l)
|
|
}
|
|
|
|
// Create the issue request
|
|
issueRequest := &github.IssueRequest{
|
|
Title: github.Ptr(title),
|
|
Body: github.Ptr(body),
|
|
Assignees: &assignees,
|
|
Labels: &labels,
|
|
}
|
|
|
|
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(client *github.Client, 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 with filtering options")),
|
|
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("labels",
|
|
mcp.Description("Comma-separated list of labels to filter by"),
|
|
),
|
|
mcp.WithString("sort",
|
|
mcp.Description("Sort by ('created', 'updated', 'comments')"),
|
|
),
|
|
mcp.WithString("direction",
|
|
mcp.Description("Sort direction ('asc', 'desc')"),
|
|
),
|
|
mcp.WithString("since",
|
|
mcp.Description("Filter by date (ISO 8601 timestamp)"),
|
|
),
|
|
mcp.WithNumber("page",
|
|
mcp.Description("Page number"),
|
|
),
|
|
mcp.WithNumber("per_page",
|
|
mcp.Description("Results per page"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner := request.Params.Arguments["owner"].(string)
|
|
repo := request.Params.Arguments["repo"].(string)
|
|
|
|
opts := &github.IssueListByRepoOptions{}
|
|
|
|
// Set optional parameters if provided
|
|
if state, ok := request.Params.Arguments["state"].(string); ok && state != "" {
|
|
opts.State = state
|
|
}
|
|
|
|
if labels, ok := request.Params.Arguments["labels"].(string); ok && labels != "" {
|
|
opts.Labels = parseCommaSeparatedList(labels)
|
|
}
|
|
|
|
if sort, ok := request.Params.Arguments["sort"].(string); ok && sort != "" {
|
|
opts.Sort = sort
|
|
}
|
|
|
|
if direction, ok := request.Params.Arguments["direction"].(string); ok && direction != "" {
|
|
opts.Direction = direction
|
|
}
|
|
|
|
if since, ok := request.Params.Arguments["since"].(string); ok && 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.Params.Arguments["page"].(float64); ok {
|
|
opts.Page = int(page)
|
|
}
|
|
|
|
if perPage, ok := request.Params.Arguments["per_page"].(float64); ok {
|
|
opts.PerPage = int(perPage)
|
|
}
|
|
|
|
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(client *github.Client, 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.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 ('open' or 'closed')"),
|
|
),
|
|
mcp.WithString("labels",
|
|
mcp.Description("Comma-separated list of new labels"),
|
|
),
|
|
mcp.WithString("assignees",
|
|
mcp.Description("Comma-separated list of new assignees"),
|
|
),
|
|
mcp.WithNumber("milestone",
|
|
mcp.Description("New milestone number"),
|
|
),
|
|
),
|
|
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
owner := request.Params.Arguments["owner"].(string)
|
|
repo := request.Params.Arguments["repo"].(string)
|
|
issueNumber := int(request.Params.Arguments["issue_number"].(float64))
|
|
|
|
// Create the issue request with only provided fields
|
|
issueRequest := &github.IssueRequest{}
|
|
|
|
// Set optional parameters if provided
|
|
if title, ok := request.Params.Arguments["title"].(string); ok && title != "" {
|
|
issueRequest.Title = github.Ptr(title)
|
|
}
|
|
|
|
if body, ok := request.Params.Arguments["body"].(string); ok && body != "" {
|
|
issueRequest.Body = github.Ptr(body)
|
|
}
|
|
|
|
if state, ok := request.Params.Arguments["state"].(string); ok && state != "" {
|
|
issueRequest.State = github.Ptr(state)
|
|
}
|
|
|
|
if labels, ok := request.Params.Arguments["labels"].(string); ok && labels != "" {
|
|
labelsList := parseCommaSeparatedList(labels)
|
|
issueRequest.Labels = &labelsList
|
|
}
|
|
|
|
if assignees, ok := request.Params.Arguments["assignees"].(string); ok && assignees != "" {
|
|
assigneesList := parseCommaSeparatedList(assignees)
|
|
issueRequest.Assignees = &assigneesList
|
|
}
|
|
|
|
if milestone, ok := request.Params.Arguments["milestone"].(float64); ok {
|
|
milestoneNum := int(milestone)
|
|
issueRequest.Milestone = &milestoneNum
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|