9d130049e9
* Add opt-in intent-aware Copilot issue assignment tool Add `assign_copilot_to_issue_with_intent` in a new non-default toolset `copilot_issue_intents`. The tool uses GraphQL's object-form `assignees: [AssigneeUpdateInput!]` so intent metadata (rationale, confidence, is_suggestion) is attached to the Copilot entry only, while existing assignees are preserved. - Reuses the existing Copilot actor lookup, target-repository resolution, base_ref, custom_instructions, GraphQL-Features header, and direct-assignment PR polling behavior. - `is_suggestion: true` records a pending Copilot assignment intent, returns a suggestion-shaped result, and does not launch Copilot or poll for a linked PR. - `rationale` is capped at 280 characters (schema + runtime); `confidence` is validated against `LOW`/`MEDIUM`/`HIGH`. - Toolset is non-default so its inputs do not add schema bloat to the default tool surface; available via `copilot_issue_intents`, `all`, or explicit tool selection. Includes unit tests for direct assignment (with existing assignees and with base_ref/custom_instructions), the suggestion path, invalid rationale length, invalid confidence, and Copilot-not-available; a generated toolsnap; regenerated docs; and an e2e test for the suggestion path. Refs: github/plan-track-agentic-toolkit#683 * Address review: tighten description, require intent fields Per review feedback on #2909: - Trim the tool description to mirror assign_copilot_to_issue and add "Prefer this tool over assign_copilot_to_issue when available", removing the verbose is_suggestion narrative from the schema. - Make rationale, confidence, and is_suggestion required inputs (schema and runtime). is_suggestion is now always sent explicitly on the Copilot AssigneeUpdateInput entry. - Update unit tests to supply the newly-required fields and cover the missing-rationale and missing-confidence rejection paths. - Regenerate toolsnap and README. * Address review: dedupe copilot, require is_suggestion, update doc link Per @RossTarrant review feedback on #2909: - Reject requests where `is_suggestion` is omitted from the raw args before decoding. `mapstructure.WeakDecode` defaults missing bools to false, which would silently launch Copilot instead of recording a suggestion. Presence-check the raw map so callers make the choice explicit. - Skip the copilot-swe-agent actor when copying existing assignees so we don't send its actorId twice (once without metadata and once with intent metadata) when Copilot is already assigned. - Update the stale about-assigning-tasks-to-copilot reference to the redirect target (about-cloud-agent). Applied to the const message, both tool descriptions, and the e2e/unit-test literals that assert on that message. New unit tests cover the missing-is_suggestion rejection and the copilot-dedup behavior. Regenerated toolsnaps.
1412 lines
42 KiB
Go
1412 lines
42 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/github/github-mcp-server/internal/githubv4mock"
|
|
"github.com/github/github-mcp-server/internal/toolsnaps"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/google/go-github/v89/github"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/shurcooL/githubv4"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAssignCopilotToIssue(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Verify tool definition
|
|
serverTool := AssignCopilotToIssue(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
assert.Equal(t, "assign_copilot_to_issue", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner")
|
|
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo")
|
|
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number")
|
|
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref")
|
|
assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "custom_instructions")
|
|
assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"})
|
|
|
|
// Helper function to create pointer to githubv4.String
|
|
ptrGitHubv4String := func(s string) *githubv4.String {
|
|
v := githubv4.String(s)
|
|
return &v
|
|
}
|
|
|
|
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",
|
|
"issue_number": 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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: nil,
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "successful assignment with string issue_number",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": "123", // Some MCP clients send numeric values as strings
|
|
},
|
|
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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: nil,
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "successful assignment when there are existing assignees",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": 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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"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 {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{
|
|
githubv4.ID("existing-assignee-id"),
|
|
githubv4.ID("existing-assignee-id-2"),
|
|
githubv4.ID("copilot-swe-agent-id"),
|
|
},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: nil,
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "copilot bot not on first page of suggested actors",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": 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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: nil,
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "copilot not a suggested actor",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": 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/concepts/agents/cloud-agent/about-cloud-agent for more information.",
|
|
},
|
|
{
|
|
name: "successful assignment with base_ref specified",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"base_ref": "feature-branch",
|
|
},
|
|
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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: ptrGitHubv4String("feature-branch"),
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
{
|
|
name: "successful assignment with custom_instructions specified",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"custom_instructions": "Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings",
|
|
},
|
|
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 {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": []any{},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: nil,
|
|
CustomAgent: ptrGitHubv4String(""),
|
|
CustomInstructions: ptrGitHubv4String("Please ensure all code follows PEP 8 style guidelines and includes comprehensive docstrings"),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
},
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
),
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
t.Parallel()
|
|
// Setup client with mock
|
|
client := githubv4.NewClient(tc.mockedClient)
|
|
deps := BaseDeps{
|
|
GQLClient: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
// Create call request
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Disable polling in tests to avoid timeouts
|
|
ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0})
|
|
ctx = ContextWithDeps(ctx, deps)
|
|
|
|
// Call handler
|
|
result, err := handler(ctx, &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))
|
|
|
|
// Verify the JSON response contains expected fields
|
|
var response map[string]any
|
|
err = json.Unmarshal([]byte(textContent.Text), &response)
|
|
require.NoError(t, err, "response should be valid JSON")
|
|
assert.Equal(t, float64(123), response["issue_number"])
|
|
assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"])
|
|
assert.Equal(t, "owner", response["owner"])
|
|
assert.Equal(t, "repo", response["repo"])
|
|
assert.Contains(t, response["message"], "successfully assigned copilot to issue")
|
|
})
|
|
}
|
|
}
|
|
|
|
func Test_RequestCopilotReview(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := RequestCopilotReview(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
assert.Equal(t, "request_copilot_review", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
schema := tool.InputSchema.(*jsonschema.Schema)
|
|
assert.Contains(t, schema.Properties, "owner")
|
|
assert.Contains(t, schema.Properties, "repo")
|
|
assert.Contains(t, schema.Properties, "pullNumber")
|
|
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"})
|
|
|
|
// Setup mock PR for success case
|
|
mockPR := &github.PullRequest{
|
|
Number: github.Ptr(42),
|
|
Title: github.Ptr("Test PR"),
|
|
State: github.Ptr("open"),
|
|
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
|
|
Head: &github.PullRequestBranch{
|
|
SHA: github.Ptr("abcd1234"),
|
|
Ref: github.Ptr("feature-branch"),
|
|
},
|
|
Base: &github.PullRequestBranch{
|
|
Ref: github.Ptr("main"),
|
|
},
|
|
Body: github.Ptr("This is a test PR"),
|
|
User: &github.User{
|
|
Login: github.Ptr("testuser"),
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
mockedClient *http.Client
|
|
requestArgs map[string]any
|
|
expectError bool
|
|
expectedErrMsg string
|
|
}{
|
|
{
|
|
name: "successful request",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{
|
|
path: "/repos/owner/repo/pulls/1/requested_reviewers",
|
|
requestBody: map[string]any{
|
|
"reviewers": []any{"copilot-pull-request-reviewer[bot]"},
|
|
},
|
|
}).andThen(
|
|
mockResponse(t, http.StatusCreated, mockPR),
|
|
),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"pullNumber": float64(1),
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "request fails",
|
|
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
|
|
PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
|
|
}),
|
|
}),
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"pullNumber": float64(999),
|
|
},
|
|
expectError: true,
|
|
expectedErrMsg: "failed to request copilot review",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := mustNewGHClient(t, tc.mockedClient)
|
|
serverTool := RequestCopilotReview(translations.NullTranslationHelper)
|
|
deps := BaseDeps{
|
|
Client: client,
|
|
}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
|
|
|
|
if tc.expectError {
|
|
require.NoError(t, err)
|
|
require.True(t, result.IsError)
|
|
errorContent := getErrorResult(t, result)
|
|
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.False(t, result.IsError)
|
|
assert.NotNil(t, result)
|
|
assert.Len(t, result.Content, 1)
|
|
|
|
textContent := getTextResult(t, result)
|
|
require.Equal(t, "", textContent.Text)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAssignCopilotToIssueWithIntent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
serverTool := AssignCopilotToIssueWithIntent(translations.NullTranslationHelper)
|
|
tool := serverTool.Tool
|
|
require.NoError(t, toolsnaps.Test(tool.Name, tool))
|
|
|
|
assert.Equal(t, "assign_copilot_to_issue_with_intent", tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.Equal(t, "copilot_issue_intents", string(serverTool.Toolset.ID),
|
|
"tool must live in the non-default copilot_issue_intents toolset")
|
|
assert.False(t, serverTool.Toolset.Default,
|
|
"copilot_issue_intents toolset must not be a default toolset")
|
|
|
|
require.NotNil(t, tool.Annotations)
|
|
assert.False(t, tool.Annotations.ReadOnlyHint, "tool must not be read-only")
|
|
|
|
schema := tool.InputSchema.(*jsonschema.Schema)
|
|
for _, prop := range []string{
|
|
"owner", "repo", "issue_number",
|
|
"base_ref", "custom_instructions",
|
|
"rationale", "confidence", "is_suggestion",
|
|
} {
|
|
assert.Contains(t, schema.Properties, prop)
|
|
}
|
|
assert.ElementsMatch(t, schema.Required, []string{
|
|
"owner", "repo", "issue_number",
|
|
"rationale", "confidence", "is_suggestion",
|
|
})
|
|
|
|
rationaleSchema := schema.Properties["rationale"]
|
|
require.NotNil(t, rationaleSchema.MaxLength)
|
|
assert.Equal(t, 280, *rationaleSchema.MaxLength)
|
|
|
|
confidenceSchema := schema.Properties["confidence"]
|
|
assert.ElementsMatch(t, confidenceSchema.Enum, []any{"LOW", "MEDIUM", "HIGH"})
|
|
|
|
// Common query mocks reused across happy-path scenarios.
|
|
suggestedActorsMatcher := func() githubv4mock.Matcher {
|
|
return 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",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
getIssueMatcher := func(existingAssignees []any) githubv4mock.Matcher {
|
|
return githubv4mock.NewQueryMatcher(
|
|
struct {
|
|
Repository struct {
|
|
ID githubv4.ID
|
|
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{
|
|
"id": githubv4.ID("test-repo-id"),
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"assignees": map[string]any{
|
|
"nodes": existingAssignees,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
mutationMatcher := func(input UpdateIssueInput) githubv4mock.Matcher {
|
|
return githubv4mock.NewMutationMatcher(
|
|
struct {
|
|
UpdateIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
Number githubv4.Int
|
|
URL githubv4.String
|
|
}
|
|
} `graphql:"updateIssue(input: $input)"`
|
|
}{},
|
|
input,
|
|
nil,
|
|
githubv4mock.DataResponse(map[string]any{
|
|
"updateIssue": map[string]any{
|
|
"issue": map[string]any{
|
|
"id": githubv4.ID("test-issue-id"),
|
|
"number": githubv4.Int(123),
|
|
"url": githubv4.String("https://github.com/owner/repo/issues/123"),
|
|
},
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
|
|
ptrStr := func(s string) *githubv4.String { v := githubv4.String(s); return &v }
|
|
ptrBool := func(b bool) *githubv4.Boolean { v := githubv4.Boolean(b); return &v }
|
|
ptrConfidence := func(c AssignmentConfidenceLevel) *AssignmentConfidenceLevel { return &c }
|
|
|
|
tests := []struct {
|
|
name string
|
|
requestArgs map[string]any
|
|
mockedClient *http.Client
|
|
expectToolError bool
|
|
expectedToolErrMsg string
|
|
expectSuggestion bool
|
|
}{
|
|
{
|
|
name: "direct assignment with rationale and confidence preserves existing assignees",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "Well-scoped task with clear acceptance criteria.",
|
|
"confidence": "HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
suggestedActorsMatcher(),
|
|
getIssueMatcher([]any{
|
|
map[string]any{"id": githubv4.ID("existing-assignee-id")},
|
|
}),
|
|
mutationMatcher(UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
Assignees: []AssigneeUpdateInput{
|
|
{ActorID: githubv4.ID("existing-assignee-id")},
|
|
{
|
|
ActorID: githubv4.ID("copilot-swe-agent-id"),
|
|
Rationale: ptrStr("Well-scoped task with clear acceptance criteria."),
|
|
Confidence: ptrConfidence(AssignmentConfidenceLevelHigh),
|
|
Suggest: ptrBool(false),
|
|
},
|
|
},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
CustomAgent: ptrStr(""),
|
|
CustomInstructions: ptrStr(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "direct assignment with base_ref and custom_instructions",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"base_ref": "feature-branch",
|
|
"custom_instructions": "Follow PEP 8.",
|
|
"rationale": "Task benefits from a linting-focused agent.",
|
|
"confidence": "medium",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
suggestedActorsMatcher(),
|
|
getIssueMatcher([]any{}),
|
|
mutationMatcher(UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
Assignees: []AssigneeUpdateInput{
|
|
{
|
|
ActorID: githubv4.ID("copilot-swe-agent-id"),
|
|
Rationale: ptrStr("Task benefits from a linting-focused agent."),
|
|
Confidence: ptrConfidence(AssignmentConfidenceLevelMedium),
|
|
Suggest: ptrBool(false),
|
|
},
|
|
},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
BaseRef: ptrStr("feature-branch"),
|
|
CustomAgent: ptrStr(""),
|
|
CustomInstructions: ptrStr("Follow PEP 8."),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "suggestion path omits agentAssignment and returns suggestion-shaped result",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "Looks like a good candidate.",
|
|
"confidence": "LOW",
|
|
"is_suggestion": true,
|
|
// base_ref and custom_instructions should be ignored when is_suggestion=true.
|
|
"base_ref": "feature-branch",
|
|
"custom_instructions": "should be ignored",
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
suggestedActorsMatcher(),
|
|
getIssueMatcher([]any{}),
|
|
mutationMatcher(UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
Assignees: []AssigneeUpdateInput{
|
|
{
|
|
ActorID: githubv4.ID("copilot-swe-agent-id"),
|
|
Rationale: ptrStr("Looks like a good candidate."),
|
|
Confidence: ptrConfidence(AssignmentConfidenceLevelLow),
|
|
Suggest: ptrBool(true),
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
expectSuggestion: true,
|
|
},
|
|
{
|
|
name: "existing copilot assignee is deduplicated from preserved assignees",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "Already assigned; refreshing intent.",
|
|
"confidence": "HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(
|
|
suggestedActorsMatcher(),
|
|
getIssueMatcher([]any{
|
|
map[string]any{"id": githubv4.ID("existing-assignee-id")},
|
|
map[string]any{"id": githubv4.ID("copilot-swe-agent-id")},
|
|
}),
|
|
// Expect copilot to appear only once, carrying the intent metadata.
|
|
mutationMatcher(UpdateIssueInput{
|
|
ID: githubv4.ID("test-issue-id"),
|
|
Assignees: []AssigneeUpdateInput{
|
|
{ActorID: githubv4.ID("existing-assignee-id")},
|
|
{
|
|
ActorID: githubv4.ID("copilot-swe-agent-id"),
|
|
Rationale: ptrStr("Already assigned; refreshing intent."),
|
|
Confidence: ptrConfidence(AssignmentConfidenceLevelHigh),
|
|
Suggest: ptrBool(false),
|
|
},
|
|
},
|
|
AgentAssignment: &AgentAssignmentInput{
|
|
CustomAgent: ptrStr(""),
|
|
CustomInstructions: ptrStr(""),
|
|
TargetRepositoryID: githubv4.ID("test-repo-id"),
|
|
},
|
|
}),
|
|
),
|
|
},
|
|
{
|
|
name: "missing rationale is rejected",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"confidence": "HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "rationale is required",
|
|
},
|
|
{
|
|
name: "rationale exceeding 280 characters is rejected",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": strings.Repeat("a", 281),
|
|
"confidence": "HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "rationale must be 280 characters or less",
|
|
},
|
|
{
|
|
name: "missing confidence is rejected",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "A good candidate.",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "confidence is required",
|
|
},
|
|
{
|
|
name: "missing is_suggestion is rejected",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "A good candidate.",
|
|
"confidence": "HIGH",
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "is_suggestion is required",
|
|
},
|
|
{
|
|
name: "invalid confidence value is rejected",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "A good candidate.",
|
|
"confidence": "SUPER_HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
mockedClient: githubv4mock.NewMockedHTTPClient(),
|
|
expectToolError: true,
|
|
expectedToolErrMsg: "confidence must be one of: LOW, MEDIUM, HIGH",
|
|
},
|
|
{
|
|
name: "copilot not a suggested actor",
|
|
requestArgs: map[string]any{
|
|
"owner": "owner",
|
|
"repo": "repo",
|
|
"issue_number": float64(123),
|
|
"rationale": "A good candidate.",
|
|
"confidence": "HIGH",
|
|
"is_suggestion": false,
|
|
},
|
|
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",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := githubv4.NewClient(tc.mockedClient)
|
|
deps := BaseDeps{GQLClient: client}
|
|
handler := serverTool.Handler(deps)
|
|
|
|
request := createMCPRequest(tc.requestArgs)
|
|
|
|
// Disable polling for direct-assignment paths.
|
|
ctx := ContextWithPollConfig(context.Background(), PollConfig{MaxAttempts: 0})
|
|
ctx = ContextWithDeps(ctx, deps)
|
|
|
|
result, err := handler(ctx, &request)
|
|
require.NoError(t, err)
|
|
|
|
textContent := getTextResult(t, result)
|
|
|
|
if tc.expectToolError {
|
|
require.True(t, result.IsError, "expected tool error, got: %s", textContent.Text)
|
|
assert.Contains(t, textContent.Text, tc.expectedToolErrMsg)
|
|
return
|
|
}
|
|
|
|
require.False(t, result.IsError, "unexpected tool error: %s", textContent.Text)
|
|
|
|
var response map[string]any
|
|
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response), "response should be valid JSON")
|
|
assert.Equal(t, float64(123), response["issue_number"])
|
|
assert.Equal(t, "https://github.com/owner/repo/issues/123", response["issue_url"])
|
|
assert.Equal(t, "owner", response["owner"])
|
|
assert.Equal(t, "repo", response["repo"])
|
|
|
|
if tc.expectSuggestion {
|
|
assert.Equal(t, true, response["is_suggestion"])
|
|
assert.Contains(t, response["message"], "pending copilot assignment suggestion")
|
|
assert.NotContains(t, response, "pull_request",
|
|
"suggestion path must not claim PR creation")
|
|
assert.NotContains(t, response, "note",
|
|
"suggestion path must not include the PR-pending note")
|
|
} else {
|
|
assert.Equal(t, false, response["is_suggestion"])
|
|
assert.Contains(t, response["message"], "successfully assigned copilot to issue")
|
|
}
|
|
})
|
|
}
|
|
}
|