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 } // Apply content filtering filterCfg := &ContentFilteringConfig{ DisableContentFiltering: false, // Default to enabled } // TODO: Pass server configuration through client context once it's available filteredIssue := FilterIssue(issue, filterCfg) r, err := json.Marshal(filteredIssue) 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 } // Apply content filtering filterCfg := &ContentFilteringConfig{ DisableContentFiltering: false, // Default to enabled } // TODO: Pass server configuration through client context once it's available // Apply filtering to both issues and pull requests in the search results if result.Issues != nil { filteredItems := make([]*github.Issue, len(result.Issues)) for i, issue := range result.Issues { filteredItems[i] = FilterIssue(issue, filterCfg) } result.Issues = filteredItems } 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.Params.Arguments["page"].(float64); ok { opts.Page = int(page) } if perPage, ok := request.Params.Arguments["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 } // Apply content filtering filterCfg := &ContentFilteringConfig{ DisableContentFiltering: false, // Default to enabled } // TODO: Pass server configuration through client context once it's available filteredIssues := FilterIssues(issues, filterCfg) r, err := json.Marshal(filteredIssues) 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 } // Apply content filtering filterCfg := &ContentFilteringConfig{ DisableContentFiltering: false, // Default to enabled } // TODO: Pass server configuration through client context once it's available filteredComments := FilterIssueComments(comments, filterCfg) r, err := json.Marshal(filteredComments) 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) }