@@ -5,13 +5,15 @@ import (
55 "encoding/json"
66 "fmt"
77 "io"
8+ "math"
89 "net/http"
910 "time"
1011
1112 "github.com/github/github-mcp-server/pkg/translations"
1213 "github.com/google/go-github/v69/github"
1314 "github.com/mark3labs/mcp-go/mcp"
1415 "github.com/mark3labs/mcp-go/server"
16+ "github.com/shurcooL/githubv4"
1517)
1618
1719// GetIssue creates a tool to get details of a specific issue in a GitHub repository.
@@ -711,6 +713,178 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
711713 }
712714}
713715
716+ // AssignCopilotToIssue creates a tool to assign a Copilot to an issue.
717+ // Note that this tool will not work on GHES where this feature is unsupported. In future, we should not expose this
718+ // tool if the configured host does not support it.
719+ func AssignCopilotToIssue (getGQLClient GetGQLClientFn , t translations.TranslationHelperFunc ) (mcp.Tool , server.ToolHandlerFunc ) {
720+ return mcp .NewTool ("assign_copilot_to_issue" ,
721+ mcp .WithDescription (t ("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION" , "Assign a Copilot to a specific issue in a GitHub repository." )),
722+ mcp .WithToolAnnotation (mcp.ToolAnnotation {
723+ Title : t ("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE" , "Assign Copilot to issue" ),
724+ ReadOnlyHint : toBoolPtr (false ),
725+ }),
726+ mcp .WithString ("owner" ,
727+ mcp .Required (),
728+ mcp .Description ("Repository owner" ),
729+ ),
730+ mcp .WithString ("repo" ,
731+ mcp .Required (),
732+ mcp .Description ("Repository name" ),
733+ ),
734+ mcp .WithNumber ("issueNumber" ,
735+ mcp .Required (),
736+ mcp .Description ("Issue number" ),
737+ ),
738+ ),
739+ func (ctx context.Context , request mcp.CallToolRequest ) (* mcp.CallToolResult , error ) {
740+ owner , err := requiredParam [string ](request , "owner" )
741+ if err != nil {
742+ return mcp .NewToolResultError (err .Error ()), nil
743+ }
744+
745+ repo , err := requiredParam [string ](request , "repo" )
746+ if err != nil {
747+ return mcp .NewToolResultError (err .Error ()), nil
748+ }
749+
750+ issueNumber , err := RequiredInt (request , "issueNumber" )
751+ if err != nil {
752+ return mcp .NewToolResultError (err .Error ()), nil
753+ }
754+ if issueNumber < math .MinInt32 || issueNumber > math .MaxInt32 {
755+ return mcp .NewToolResultError (fmt .Sprintf ("issueNumber %d overflows int32" , issueNumber )), nil
756+ }
757+
758+ client , err := getGQLClient (ctx )
759+ if err != nil {
760+ return nil , fmt .Errorf ("failed to get GitHub client: %w" , err )
761+ }
762+
763+ // First we need to get a list of assignable actors
764+ type botAssignee struct {
765+ ID githubv4.ID
766+ Login string
767+ TypeName string `graphql:"__typename"`
768+ }
769+
770+ type suggestedActorsQuery struct {
771+ Repository struct {
772+ SuggestedActors struct {
773+ Nodes []struct {
774+ Bot botAssignee `graphql:"... on Bot"`
775+ }
776+ PageInfo struct {
777+ HasNextPage bool
778+ EndCursor string
779+ }
780+ } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"`
781+ } `graphql:"repository(owner: $owner, name: $name)"`
782+ }
783+
784+ variables := map [string ]any {
785+ "owner" : githubv4 .String (owner ),
786+ "name" : githubv4 .String (repo ),
787+ "endCursor" : (* githubv4 .String )(nil ),
788+ }
789+
790+ var copilotAssignee * botAssignee
791+ for {
792+ var query suggestedActorsQuery
793+ err := client .Query (ctx , & query , variables )
794+ if err != nil {
795+ return nil , err
796+ }
797+
798+ for _ , node := range query .Repository .SuggestedActors .Nodes {
799+ if node .Bot .Login == "copilot-swe-agent" {
800+ copilotAssignee = & node .Bot
801+ break
802+ }
803+ }
804+
805+ if ! query .Repository .SuggestedActors .PageInfo .HasNextPage {
806+ break
807+ }
808+ variables ["endCursor" ] = githubv4 .String (query .Repository .SuggestedActors .PageInfo .EndCursor )
809+ }
810+
811+ if copilotAssignee == nil {
812+ return mcp .NewToolResultError ("Copilot was not found as a suggested assignee" ), nil
813+ }
814+
815+ // Next let's get the GQL Node ID and current assignees for this issue
816+ // because the only way to assign copilot is to use replaceActorsForAssignable.
817+ var getIssueQuery struct {
818+ Repository struct {
819+ Issue struct {
820+ ID githubv4.ID
821+ Assignees struct {
822+ Nodes []struct {
823+ ID githubv4.ID
824+ }
825+ } `graphql:"assignees(first: 100)"`
826+ } `graphql:"issue(number: $number)"`
827+ } `graphql:"repository(owner: $owner, name: $name)"`
828+ }
829+
830+ variables = map [string ]any {
831+ "owner" : githubv4 .String (owner ),
832+ "name" : githubv4 .String (repo ),
833+ "number" : githubv4 .Int (issueNumber ), //nolint:gosec // G115: issueNumber is guaranteed to fit into int32
834+ }
835+
836+ if err := client .Query (ctx , & getIssueQuery , variables ); err != nil {
837+ return mcp .NewToolResultError (fmt .Sprintf ("failed to get issue ID: %v" , err )), nil
838+ }
839+
840+ // Then, get all the current assignees because the only way to assign copilot is to use replaceActorsForAssignable
841+ // which replaces all assignees.
842+ var assignCopilotMutation struct {
843+ ReplaceActorsForAssignable struct {
844+ Assignable struct {
845+ ID githubv4.ID
846+ Title string
847+ Assignees struct {
848+ Nodes []struct {
849+ Login string
850+ }
851+ } `graphql:"assignees(first: 10)"`
852+ } `graphql:"... on Issue"`
853+ } `graphql:"replaceActorsForAssignable(input: $input)"`
854+ }
855+
856+ type ReplaceActorsForAssignableInput struct {
857+ AssignableID githubv4.ID `json:"assignableId"`
858+ ActorIDs []githubv4.ID `json:"actorIds"`
859+ }
860+
861+ actorIDs := make ([]githubv4.ID , len (getIssueQuery .Repository .Issue .Assignees .Nodes )+ 1 )
862+ for i , node := range getIssueQuery .Repository .Issue .Assignees .Nodes {
863+ actorIDs [i ] = node .ID
864+ }
865+ actorIDs [len (getIssueQuery .Repository .Issue .Assignees .Nodes )] = copilotAssignee .ID
866+
867+ if err := client .Mutate (
868+ ctx ,
869+ & assignCopilotMutation ,
870+ ReplaceActorsForAssignableInput {
871+ AssignableID : getIssueQuery .Repository .Issue .ID ,
872+ ActorIDs : actorIDs ,
873+ },
874+ nil ,
875+ ); err != nil {
876+ return nil , fmt .Errorf ("failed to replace actors for assignable: %w" , err )
877+ }
878+
879+ r , err := json .Marshal (assignCopilotMutation .ReplaceActorsForAssignable .Assignable )
880+ if err != nil {
881+ return nil , fmt .Errorf ("failed to marshal response: %w" , err )
882+ }
883+
884+ return mcp .NewToolResultText (string (r )), nil
885+ }
886+ }
887+
714888// parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object.
715889// Returns the parsed time or an error if parsing fails.
716890// Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15"
0 commit comments