diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5d8552ccf..99e7e8de3 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -7,6 +7,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/http" "os" "os/exec" "slices" @@ -210,7 +211,6 @@ func TestGetMe(t *testing.T) { t.Parallel() mcpClient := setupMCPClient(t) - ctx := context.Background() // When we call the "get_me" tool @@ -795,14 +795,13 @@ func TestDirectoryDeletion(t *testing.T) { } func TestRequestCopilotReview(t *testing.T) { + t.Parallel() + if getE2EHost() != "" && getE2EHost() != "https://github.com" { t.Skip("Skipping test because the host does not support copilot reviews") } - t.Parallel() - mcpClient := setupMCPClient(t) - ctx := context.Background() // First, who am I @@ -943,6 +942,112 @@ func TestRequestCopilotReview(t *testing.T) { require.Equal(t, "Bot", *reviewRequests.Users[0].Type, "expected review request to be for Bot") } +func TestAssignCopilotToIssue(t *testing.T) { + t.Parallel() + + if getE2EHost() != "" && getE2EHost() != "https://github.com" { + t.Skip("Skipping test because the host does not support copilot being assigned to issues") + } + + mcpClient := setupMCPClient(t) + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'create_repository' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := getRESTClient(t) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Create an issue + createIssueRequest := mcp.CallToolRequest{} + createIssueRequest.Params.Name = "create_issue" + createIssueRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "title": "Test issue to assign copilot to", + } + + t.Logf("Creating issue in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createIssueRequest) + require.NoError(t, err, "expected to call 'create_issue' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Assign copilot to the issue + assignCopilotRequest := mcp.CallToolRequest{} + assignCopilotRequest.Params.Name = "assign_copilot_to_issue" + assignCopilotRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "issueNumber": 1, + } + + t.Logf("Assigning copilot to issue in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, assignCopilotRequest) + require.NoError(t, err, "expected to call 'assign_copilot_to_issue' tool successfully") + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + possibleExpectedFailure := "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." + if resp.IsError && textContent.Text == possibleExpectedFailure { + t.Skip("skipping because copilot wasn't available as an assignee on this issue, it's likely that the owner doesn't have copilot enabled in their settings") + } + + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.Equal(t, "successfully assigned copilot to issue", textContent.Text) + + // Check that copilot is assigned to the issue + // MCP Server doesn't support getting assignees yet + ghClient := getRESTClient(t) + assignees, response, err := ghClient.Issues.Get(context.Background(), currentOwner, repoName, 1) + require.NoError(t, err, "expected to get issue successfully") + require.Equal(t, http.StatusOK, response.StatusCode, "expected to get issue successfully") + require.Len(t, assignees.Assignees, 1, "expected to find one assignee") + require.Equal(t, "Copilot", *assignees.Assignees[0].Login, "expected copilot to be assigned to the issue") +} + func TestPullRequestAtomicCreateAndSubmit(t *testing.T) { t.Parallel() @@ -1145,7 +1250,7 @@ func TestPullRequestReviewCommentSubmit(t *testing.T) { t.Logf("Creating repository %s/%s...", currentOwner, repoName) _, err = mcpClient.CallTool(ctx, createRepoRequest) - require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.NoError(t, err, "expected to call 'create_repository' tool successfully") require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) // Cleanup the repository after the test diff --git a/internal/githubv4mock/objects_are_equal_values.go b/internal/githubv4mock/objects_are_equal_values.go index ce463ca8a..02ba7b39a 100644 --- a/internal/githubv4mock/objects_are_equal_values.go +++ b/internal/githubv4mock/objects_are_equal_values.go @@ -1,6 +1,8 @@ // The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions.go#L166 // because I do not want to take a dependency on the entire testify module just to use this equality check. // +// There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different. +// // The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE // // MIT License @@ -69,8 +71,10 @@ func objectsAreEqualValues(expected, actual any) bool { // // This function does no assertion of any kind. func objectsAreEqual(expected, actual any) bool { - if expected == nil || actual == nil { - return expected == actual + // There is a modification in objectsAreEqual to check that typed nils are equal, even if their types are different. + // This is required because when a nil is provided as a variable, the type is not known. + if isNil(expected) && isNil(actual) { + return true } exp, ok := expected.([]byte) @@ -94,3 +98,16 @@ func objectsAreEqual(expected, actual any) bool { func isNumericType(t reflect.Type) bool { return t.Kind() >= reflect.Int && t.Kind() <= reflect.Complex128 } + +func isNil(i any) bool { + if i == nil { + return true + } + v := reflect.ValueOf(i) + switch v.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return v.IsNil() + default: + return false + } +} diff --git a/internal/githubv4mock/objects_are_equal_values_test.go b/internal/githubv4mock/objects_are_equal_values_test.go index fd61dd68e..d6839e794 100644 --- a/internal/githubv4mock/objects_are_equal_values_test.go +++ b/internal/githubv4mock/objects_are_equal_values_test.go @@ -1,5 +1,7 @@ // The contents of this file are taken from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/assert/assertions_test.go#L140-L174 // +// There is a modification to test objectsAreEqualValues to check that typed nils are equal, even if their types are different. + // The original license, copied from https://github.com/stretchr/testify/blob/016e2e9c269209287f33ec203f340a9a723fe22c/LICENSE // // MIT License @@ -55,6 +57,8 @@ func TestObjectsAreEqualValues(t *testing.T) { {3.14, complex128(1e+100 + 1e+100i), false}, {complex128(1e+10 + 1e+10i), complex64(1e+10 + 1e+10i), true}, {complex64(1e+10 + 1e+10i), complex128(1e+10 + 1e+10i), true}, + {(*string)(nil), nil, true}, // typed nil vs untyped nil + {(*string)(nil), (*int)(nil), true}, // different typed nils } for _, c := range cases { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 7c8451d39..68e7a36cd 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -6,12 +6,15 @@ import ( "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. @@ -264,7 +267,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.WithArray("assignees", mcp.Description("Usernames to assign to this issue"), mcp.Items( - map[string]interface{}{ + map[string]any{ "type": "string", }, ), @@ -272,7 +275,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t mcp.WithArray("labels", mcp.Description("Labels to apply to this issue"), mcp.Items( - map[string]interface{}{ + map[string]any{ "type": "string", }, ), @@ -711,6 +714,200 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun } } +// 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" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 26458fa27..cd715de68 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -3,13 +3,16 @@ package github import ( "context" "encoding/json" + "fmt" "net/http" "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v69/github" "github.com/migueleliasweb/go-github-mock/src/mock" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1119,3 +1122,422 @@ func Test_GetIssueComments(t *testing.T) { }) } } + +func TestAssignCopilotToIssue(t *testing.T) { + t.Parallel() + + // Verify tool definition + mockClient := githubv4.NewClient(nil) + tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "assign_copilot_to_issue", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "issueNumber") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issueNumber"}) + + var pageOfFakeBots = func(n int) []struct{} { + // We don't _really_ need real bots here, just objects that count as entries for the page + bots := make([]struct{}, n) + for i := range n { + bots[i] = struct{}{} + } + return bots + } + + tests := []struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedToolErrMsg string + }{ + { + name: "successful assignment when there are no existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + 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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "successful assignment when there are existing assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + 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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("existing-assignee-id"), + }, + map[string]any{ + "id": githubv4.ID("existing-assignee-id-2"), + }, + }, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{ + githubv4.ID("existing-assignee-id"), + githubv4.ID("existing-assignee-id-2"), + githubv4.ID("copilot-swe-agent-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "copilot bot not on first page of suggested actors", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + // First page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": pageOfFakeBots(100), + "pageInfo": map[string]any{ + "hasNextPage": true, + "endCursor": githubv4.String("next-page-cursor"), + }, + }, + }, + }), + ), + // Second page of suggested actors + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": githubv4.String("next-page-cursor"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + 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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + ReplaceActorsForAssignable struct { + Typename string `graphql:"__typename"` + } `graphql:"replaceActorsForAssignable(input: $input)"` + }{}, + ReplaceActorsForAssignableInput{ + AssignableID: githubv4.ID("test-issue-id"), + ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + }, + nil, + githubv4mock.DataResponse(map[string]any{}), + ), + ), + }, + { + name: "copilot not a suggested actor", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issueNumber": float64(123), + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `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)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{}, + }, + }, + }), + ), + ), + expectToolError: true, + expectedToolErrMsg: "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.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + + t.Parallel() + // Setup client with mock + client := githubv4.NewClient(tc.mockedClient) + _, handler := AssignCopilotToIssue(stubGetGQLClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + + if tc.expectToolError { + require.True(t, result.IsError) + assert.Contains(t, textContent.Text, tc.expectedToolErrMsg) + return + } + + require.False(t, result.IsError, fmt.Sprintf("expected there to be no tool error, text was %s", textContent.Text)) + require.Equal(t, textContent.Text, "successfully assigned copilot to issue") + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a04e7336b..cd379ebba 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -51,6 +51,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(CreateIssue(getClient, t)), toolsets.NewServerTool(AddIssueComment(getClient, t)), toolsets.NewServerTool(UpdateIssue(getClient, t)), + toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)), ) users := toolsets.NewToolset("users", "GitHub User related tools"). AddReadTools( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy