Skip to content

Commit 701d484

Browse files
alondahariCopilot
andcommitted
Add rationale and is_suggestion support to assignees in issue_write
Update the issue_write MCP tool (IssueWrite, the FeatureFlagIssueFields-enabled variant) to accept assignees in polymorphic form: either plain strings (backward- compatible) or objects with login, rationale, confidence, and is_suggestion fields. When object-form assignees are provided, the handler: 1. Skips assignees in the standard UpdateIssue call 2. Makes a follow-up PATCH with the assignees in object form including intent metadata (rationale, confidence, suggest) This mirrors the pattern used for labels in GranularUpdateIssueLabels and type in GranularUpdateIssueType, gated behind FeatureFlagIssueFields. The REST API already supports: { "assignees": [{ "login": "octocat", "rationale": "...", "suggest": true }] } Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4f73cfd commit 701d484

5 files changed

Lines changed: 459 additions & 12 deletions

File tree

docs/feature-flags.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ runtime behavior (such as output formatting) won't appear here.
7575
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
7676

7777
- **ui_get** - Get UI data
78-
- **Required OAuth Scopes**: `repo`, `read:org`
78+
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
7979
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
8080
- `method`: The type of data to fetch (string, required)
8181
- `owner`: Repository owner (required for all methods) (string, required)
@@ -99,7 +99,7 @@ runtime behavior (such as output formatting) won't appear here.
9999

100100
- **issue_write** - Create or update issue/pull request
101101
- **Required OAuth Scopes**: `repo`
102-
- `assignees`: Usernames to assign to this issue (string[], optional)
102+
- `assignees`: Usernames to assign to this issue. ([], optional)
103103
- `body`: Issue body content (string, optional)
104104
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
105105
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)

docs/insiders-features.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
6969
- `type`: Type of this issue. Only use if issue types are enabled for this repository. Use list_issue_types tool to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter. (string, optional)
7070

7171
- **ui_get** - Get UI data
72-
- **Required OAuth Scopes**: `repo`, `read:org`
72+
- **Required OAuth Scopes (any of)**: `repo`, `read:org`
7373
- **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org`
7474
- `method`: The type of data to fetch (string, required)
7575
- `owner`: Repository owner (required for all methods) (string, required)
@@ -93,7 +93,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
9393

9494
- **issue_write** - Create or update issue/pull request
9595
- **Required OAuth Scopes**: `repo`
96-
- `assignees`: Usernames to assign to this issue (string[], optional)
96+
- `assignees`: Usernames to assign to this issue. ([], optional)
9797
- `body`: Issue body content (string, optional)
9898
- `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional)
9999
- `issue_fields`: Issue field values to set or clear. Each item requires 'field_name' and exactly one of 'value', 'field_option_name', or 'delete: true'. (object[], optional)

pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,44 @@
1515
"inputSchema": {
1616
"properties": {
1717
"assignees": {
18-
"description": "Usernames to assign to this issue",
18+
"description": "Usernames to assign to this issue.",
1919
"items": {
20-
"type": "string"
20+
"oneOf": [
21+
{
22+
"description": "GitHub username",
23+
"type": "string"
24+
},
25+
{
26+
"properties": {
27+
"confidence": {
28+
"description": "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
29+
"enum": [
30+
"LOW",
31+
"MEDIUM",
32+
"HIGH"
33+
],
34+
"type": "string"
35+
},
36+
"is_suggestion": {
37+
"description": "If true, this assignee is sent to the API as a suggestion (suggest:true) rather than a direct assignment. Whether the assignee is applied or recorded as a proposal is determined by the API.",
38+
"type": "boolean"
39+
},
40+
"login": {
41+
"description": "GitHub username",
42+
"type": "string"
43+
},
44+
"rationale": {
45+
"description": "One concise sentence explaining why this person is the right assignee. State the concrete signal (e.g. 'Owns the auth module where the bug was reported').",
46+
"maxLength": 280,
47+
"type": "string"
48+
}
49+
},
50+
"required": [
51+
"login"
52+
],
53+
"type": "object"
54+
}
55+
]
2156
},
2257
"type": "array"
2358
},

pkg/github/issues.go

Lines changed: 182 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,9 +1913,37 @@ Options are:
19131913
},
19141914
"assignees": {
19151915
Type: "array",
1916-
Description: "Usernames to assign to this issue",
1916+
Description: "Usernames to assign to this issue.",
19171917
Items: &jsonschema.Schema{
1918-
Type: "string",
1918+
OneOf: []*jsonschema.Schema{
1919+
{Type: "string", Description: "GitHub username"},
1920+
{
1921+
Type: "object",
1922+
Properties: map[string]*jsonschema.Schema{
1923+
"login": {
1924+
Type: "string",
1925+
Description: "GitHub username",
1926+
},
1927+
"rationale": {
1928+
Type: "string",
1929+
Description: "One concise sentence explaining why this person is the right assignee. " +
1930+
"State the concrete signal (e.g. 'Owns the auth module where the bug was reported').",
1931+
MaxLength: jsonschema.Ptr(280),
1932+
},
1933+
"confidence": {
1934+
Type: "string",
1935+
Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.",
1936+
Enum: []any{"LOW", "MEDIUM", "HIGH"},
1937+
},
1938+
"is_suggestion": {
1939+
Type: "boolean",
1940+
Description: "If true, this assignee is sent to the API as a suggestion (suggest:true) rather than a direct assignment. " +
1941+
"Whether the assignee is applied or recorded as a proposal is determined by the API.",
1942+
},
1943+
},
1944+
Required: []string{"login"},
1945+
},
1946+
},
19191947
},
19201948
},
19211949
"labels": {
@@ -2049,8 +2077,8 @@ Options are:
20492077
return utils.NewToolResultError(err.Error()), nil, nil
20502078
}
20512079

2052-
// Get assignees
2053-
assignees, err := OptionalStringArrayParam(args, "assignees")
2080+
// Get assignees (polymorphic: string or object with login/rationale/confidence/is_suggestion)
2081+
assignees, assigneesPayload, useAssigneeObjectForm, err := parsePolymorphicAssignees(args)
20542082
if err != nil {
20552083
return utils.NewToolResultError(err.Error()), nil, nil
20562084
}
@@ -2129,16 +2157,45 @@ Options are:
21292157
switch method {
21302158
case "create":
21312159
result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)
2160+
if err != nil || result.IsError {
2161+
return result, nil, err
2162+
}
2163+
// If object-form assignees were used on create, apply them via a follow-up PATCH
2164+
if useAssigneeObjectForm {
2165+
textContent, ok := result.Content[0].(*mcp.TextContent)
2166+
if ok {
2167+
var created MinimalResponse
2168+
if jsonErr := json.Unmarshal([]byte(textContent.Text), &created); jsonErr == nil {
2169+
if issueNum, parseErr := parseIssueNumberFromURL(created.URL); parseErr == nil {
2170+
return patchAssigneesWithIntent(ctx, client, owner, repo, issueNum, assigneesPayload)
2171+
}
2172+
}
2173+
}
2174+
}
21322175
return result, nil, err
21332176
case "update":
21342177
issueNumber, err := RequiredInt(args, "issue_number")
21352178
if err != nil {
21362179
return utils.NewToolResultError(err.Error()), nil, nil
21372180
}
2138-
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
2139-
AssigneesProvided: assigneesProvided,
2181+
// When object-form assignees are used, skip assignees in the standard
2182+
// UpdateIssue call and apply them via a separate PATCH with intent metadata.
2183+
updateAssignees := assignees
2184+
updateAssigneesProvided := assigneesProvided
2185+
if useAssigneeObjectForm {
2186+
updateAssignees = nil
2187+
updateAssigneesProvided = false
2188+
}
2189+
result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, updateAssignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{
2190+
AssigneesProvided: updateAssigneesProvided,
21402191
LabelsProvided: labelsProvided,
21412192
})
2193+
if err != nil || result.IsError {
2194+
return result, nil, err
2195+
}
2196+
if useAssigneeObjectForm {
2197+
return patchAssigneesWithIntent(ctx, client, owner, repo, issueNumber, assigneesPayload)
2198+
}
21422199
return result, nil, err
21432200
default:
21442201
return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil
@@ -2452,6 +2509,125 @@ type UpdateIssueOptions struct {
24522509
LabelsProvided bool
24532510
}
24542511

2512+
// assigneeWithIntent represents the object form of an assignee entry, allowing a
2513+
// rationale, confidence level, and/or suggest flag to be sent alongside the login.
2514+
type assigneeWithIntent struct {
2515+
Login string `json:"login"`
2516+
Rationale string `json:"rationale,omitempty"`
2517+
Confidence string `json:"confidence,omitempty"`
2518+
Suggest bool `json:"suggest,omitempty"`
2519+
}
2520+
2521+
// assigneesUpdateRequest is a custom request body for updating an issue's assignees
2522+
// where individual assignees may optionally include a rationale. Each element of
2523+
// Assignees is either a string (login) or an assigneeWithIntent object.
2524+
type assigneesUpdateRequest struct {
2525+
Assignees []any `json:"assignees"`
2526+
}
2527+
2528+
// parsePolymorphicAssignees parses the assignees parameter, which may be an array
2529+
// of strings or an array of objects with login, rationale, confidence, is_suggestion.
2530+
// Returns the plain login strings, the polymorphic payload, and whether object form is used.
2531+
func parsePolymorphicAssignees(args map[string]any) ([]string, []any, bool, error) {
2532+
assigneesRaw, ok := args["assignees"]
2533+
if !ok || assigneesRaw == nil {
2534+
return []string{}, nil, false, nil
2535+
}
2536+
assigneesSlice, ok := assigneesRaw.([]any)
2537+
if !ok {
2538+
if strs, ok := assigneesRaw.([]string); ok {
2539+
assigneesSlice = make([]any, len(strs))
2540+
for i, s := range strs {
2541+
assigneesSlice[i] = s
2542+
}
2543+
} else {
2544+
return nil, nil, false, fmt.Errorf("parameter assignees must be an array")
2545+
}
2546+
}
2547+
2548+
useObjectForm := false
2549+
logins := make([]string, 0, len(assigneesSlice))
2550+
payload := make([]any, 0, len(assigneesSlice))
2551+
for _, item := range assigneesSlice {
2552+
switch v := item.(type) {
2553+
case string:
2554+
logins = append(logins, v)
2555+
payload = append(payload, v)
2556+
case map[string]any:
2557+
login, err := RequiredParam[string](v, "login")
2558+
if err != nil {
2559+
return nil, nil, false, fmt.Errorf("each assignee object must have a 'login' string")
2560+
}
2561+
logins = append(logins, login)
2562+
rationale, err := OptionalParam[string](v, "rationale")
2563+
if err != nil {
2564+
return nil, nil, false, err
2565+
}
2566+
rationale = strings.TrimSpace(rationale)
2567+
if len([]rune(rationale)) > 280 {
2568+
return nil, nil, false, fmt.Errorf("assignee rationale must be 280 characters or less")
2569+
}
2570+
confidence, err := OptionalParam[string](v, "confidence")
2571+
if err != nil {
2572+
return nil, nil, false, err
2573+
}
2574+
confidence = normalizeConfidence(confidence)
2575+
if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" {
2576+
return nil, nil, false, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH")
2577+
}
2578+
isSuggestion, err := OptionalParam[bool](v, "is_suggestion")
2579+
if err != nil {
2580+
return nil, nil, false, err
2581+
}
2582+
if rationale == "" && !isSuggestion && confidence == "" {
2583+
payload = append(payload, login)
2584+
} else {
2585+
useObjectForm = true
2586+
payload = append(payload, assigneeWithIntent{Login: login, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion})
2587+
}
2588+
default:
2589+
return nil, nil, false, fmt.Errorf("each assignee must be a string or an object with 'login' and optional 'rationale', 'confidence', and/or 'is_suggestion'")
2590+
}
2591+
}
2592+
return logins, payload, useObjectForm, nil
2593+
}
2594+
2595+
// patchAssigneesWithIntent sends a PATCH request with object-form assignees
2596+
// that include rationale, confidence, and/or suggest metadata.
2597+
func patchAssigneesWithIntent(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, assigneesPayload []any) (*mcp.CallToolResult, any, error) {
2598+
body := &assigneesUpdateRequest{Assignees: assigneesPayload}
2599+
apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber)
2600+
req, err := client.NewRequest(ctx, "PATCH", apiURL, body)
2601+
if err != nil {
2602+
return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil
2603+
}
2604+
2605+
issue := &github.Issue{}
2606+
resp, err := client.Do(req, issue)
2607+
if err != nil {
2608+
return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue assignees", resp, err), nil, nil
2609+
}
2610+
defer func() { _ = resp.Body.Close() }()
2611+
2612+
r, err := json.Marshal(MinimalResponse{
2613+
ID: fmt.Sprintf("%d", issue.GetID()),
2614+
URL: issue.GetHTMLURL(),
2615+
})
2616+
if err != nil {
2617+
return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil
2618+
}
2619+
return utils.NewToolResultText(string(r)), nil, nil
2620+
}
2621+
2622+
// parseIssueNumberFromURL extracts the issue number from a GitHub issue URL.
2623+
func parseIssueNumberFromURL(url string) (int, error) {
2624+
parts := strings.Split(url, "/")
2625+
if len(parts) == 0 {
2626+
return 0, fmt.Errorf("invalid issue URL: %s", url)
2627+
}
2628+
return strconv.Atoi(parts[len(parts)-1])
2629+
}
2630+
24552631
func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) {
24562632
updateOptions := UpdateIssueOptions{
24572633
AssigneesProvided: len(assignees) > 0,

0 commit comments

Comments
 (0)